diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 5a2a05a..ed5067e 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -5,7 +5,16 @@ "Bash(npm install:*)", "Bash(node -e \"require\\(''''c:/CPANEL/PANEL_BASES_ANEXO24/node_modules/exceljs''''\\)\")", "Bash(docker-compose up:*)", - "Bash(npx svelte-check *)" + "Bash(npx svelte-check *)", + "Bash(grep -rnE \"SERVER=|Server=|,{port}|:{port}|adjust_server_for_docker|conn_str|connection_string|pyodbc.connect|DRIVER=\" backend/api/v1/modules/scaii/contribuyente_legacy/service.py)", + "Bash(node_modules/.bin/vitest run *)", + "Bash(npm run *)", + "Bash(npx vitest *)", + "Bash(xargs -I{} sh -c 'echo \"--- {} ---\"; head -25 {}')", + "Bash(node -e \"require\\('archiver'\\)\")", + "Bash(node -e \"console.log\\(require\\('@types/archiver/package.json'\\).version\\)\")", + "Bash(grep -vE \"checkOrigin|deprecated|^$|svelte-kit sync|> \")", + "Bash(grep -vE \"checkOrigin|deprecated|^$|svelte-kit sync|^> \")" ] } } diff --git a/.env.example b/.env.example index ba4e110..c75e5b2 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,18 @@ PANEL_MSSQL_USER=sa PANEL_MSSQL_PASSWORD=Clave.2025 # En contenedor Docker: reescribe localhost en server_name → host.docker.internal # PANEL_MSSQL_DOCKER=true +# Depuración de bases duplicadas: tolerancia de tamaño (fracción 0–1) para marcar una base como +# segura para borrar del servidor viejo. El nuevo debe pesar >= (1 - tolerancia) del viejo. Default 0.2. +# PANEL_DEDUP_SIZE_TOLERANCE=0.2 +# "Mandar al nuevo": carpeta donde el SQL viejo escribe el .bak (default: data_folder del servidor). +# PANEL_DEDUP_BACKUP_FOLDER= +# Ventana de verificación tras enviar (ms) antes de dar la base por "en tránsito". Default 180000. +# PANEL_DEDUP_MOVE_VERIFY_TIMEOUT_MS=180000 +# Intervalo de sondeo de la verificación (ms). Default 5000. +# PANEL_DEDUP_MOVE_POLL_MS=5000 +# requestTimeout de SQL Server para BACKUP/DROP (el default de node-mssql, 15 s, no alcanza para +# bases reales). Default 3600000 (1 h). +# PANEL_DEDUP_DDL_TIMEOUT_MS=3600000 # PostgreSQL - Usuarios del panel + catálogo ControlDesk (tablas a24c.* las crea otra app) DB_POSTGRES_HOST=localhost @@ -39,9 +51,15 @@ SMTP_USE_TLS=true # node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" CLOUDRESTORE_API_TOKEN= -# Clave AES-256 (32 bytes) para cifrar credenciales SQL/SSH de restore_targets en reposo. -# Solo la usa el panel; CloudRestoreAS no necesita esta variable. -# Generar con: +# Clave compartida con a24c para cifrar la contraseña SQL de los nodos en reposo. +# El panel cifra en Fernet (AES-128-CBC + HMAC) derivando la clave como sha256(SECRET_KEY), +# EXACTAMENTE igual que a24c, para que a24c pueda descifrar database_nodes.sql_password. +# ⚠️ DEBE ser idéntica a la SECRET_KEY del backend de a24c, o a24c no podrá conectar. +SECRET_KEY= + +# [LEGADO] Clave AES-256 (32 bytes) del formato antiguo `gcm:` del panel. Solo se usa para +# LEER credenciales cifradas antes de migrar a Fernet; las nuevas se escriben con SECRET_KEY. +# Puede quedar vacía en instalaciones nuevas. Generar (si aplica) con: # node -e "console.log(require('crypto').randomBytes(32).toString('base64'))" ENCRYPTION_KEY= diff --git a/Jenkinsfile b/Jenkinsfile index b28469e..39971b6 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -69,6 +69,10 @@ pipeline { npm config set strict-ssl false npm ci --legacy-peer-deps npm run check + # `check` es svelte-kit sync + tsc --noEmit, y tsc NO revisa las plantillas .svelte: + # sin los tests unitarios el pipeline no detectaba, por ejemplo, un nombre de icono + # que no existe en la fuente (se renderiza como texto literal y desborda el layout). + npm run test ' ''' } diff --git a/database/schema.sql b/database/schema.sql index 5f68e79..366ab3c 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -140,14 +140,6 @@ CREATE TABLE IF NOT EXISTS a24c.restore_job_logs ( restored_at TIMESTAMPTZ NOT NULL DEFAULT now() ); --- Descartar sin borrar: las vistas de restaurados/fallidos filtran por dismissed_at IS NULL, --- pero listRestoreJobLogSummaries sigue contando TODO. Un DELETE real bajaría los contadores de --- /servidores-restauracion y podría retroceder la última restauración exitosa de un servidor. -ALTER TABLE a24c.restore_job_logs ADD COLUMN IF NOT EXISTS dismissed_at TIMESTAMPTZ; -ALTER TABLE a24c.restore_job_logs ADD COLUMN IF NOT EXISTS dismissed_by VARCHAR(128); -CREATE INDEX IF NOT EXISTS idx_a24c_restore_job_logs_pendientes - ON a24c.restore_job_logs (status, restored_at DESC) WHERE dismissed_at IS NULL; - CREATE INDEX IF NOT EXISTS idx_a24c_restore_job_logs_target ON a24c.restore_job_logs (restore_target_id); CREATE INDEX IF NOT EXISTS idx_a24c_restore_job_logs_restored_at diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 3436600..9e56053 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -21,13 +21,18 @@ services: # JWT - JWT_SECRET=${JWT_SECRET} - # Cifrado en reposo de las credenciales SQL/SSH de restore_targets (AES-256-GCM). - # Sin esta clave el panel no puede descifrar la contraseña SSH y por lo tanto no puede - # instalar CRAS en un servidor. - - ENCRYPTION_KEY=${ENCRYPTION_KEY:?ENCRYPTION_KEY es obligatoria} + # Clave Fernet compartida con a24c para cifrar la contraseña SQL de los nodos. + # DEBE ser idéntica a la SECRET_KEY del backend de a24c. + - SECRET_KEY=${SECRET_KEY} + # [LEGADO] Solo si existen credenciales en el formato antiguo `gcm:` por migrar. Hoy TODAS + # las de restore_targets siguen en ese formato, así que el instalador remoto de CRAS aún + # depende de esta clave para descifrar la contraseña SSH del destino. + - ENCRYPTION_KEY=${ENCRYPTION_KEY:-} - # Token de servicio que usan los agentes CloudRestoreAS en /api/restore/*. Los endpoints - # fallan cerrado (500) sin él, y el instalador remoto lo siembra en el .env del agente. + # Token de servicio que usan los agentes CloudRestoreAS en /api/restore/*. Obligatorio a + # propósito: service-auth.ts falla CERRADO con 500 si falta, así que sin `:?` el deploy + # arrancaría bien y los agentes recibirían 500 sin que nadie sepa por qué. Mismo patrón + # que MINIO_ROOT_USER en el compose de producción de a24c. - CLOUDRESTORE_API_TOKEN=${CLOUDRESTORE_API_TOKEN:?CLOUDRESTORE_API_TOKEN es obligatorio} # Ruta de respaldos montada desde el host diff --git a/docker-compose.yml b/docker-compose.yml index c0de8b4..d02a3e0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -38,6 +38,8 @@ services: # Integración CloudRestoreAS - CLOUDRESTORE_API_TOKEN=${CLOUDRESTORE_API_TOKEN} + # Clave Fernet compartida con a24c (debe coincidir con la SECRET_KEY de a24c) + - SECRET_KEY=${SECRET_KEY} - ENCRYPTION_KEY=${ENCRYPTION_KEY} # --- Distribución de versiones de CloudRestoreAS ----------------------- diff --git a/package-lock.json b/package-lock.json index 0c4f975..6e99a0e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "transmitiras-dashboard", "version": "0.0.1", "dependencies": { + "archiver": "^5.3.2", "bcrypt": "^6.0.0", "bootstrap": "^5.3.3", "dotenv": "^16.4.5", @@ -24,6 +25,7 @@ "@sveltejs/adapter-node": "^5.5.2", "@sveltejs/kit": "^2.0.0", "@sveltejs/vite-plugin-svelte": "^4.0.0-next.0", + "@types/archiver": "^5.3.4", "@types/bcrypt": "^6.0.0", "@types/jsonwebtoken": "^9.0.10", "@types/mssql": "^9.1.5", @@ -1498,6 +1500,16 @@ "integrity": "sha512-7qSgZbincDDDFyRweCIEvZULFAw5iz/DeunhvuxpL31nfntX3P4Yd4HkHBRg9H8CdqY1e5WFN1PZIz/REL9MVQ==", "license": "MIT" }, + "node_modules/@types/archiver": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/@types/archiver/-/archiver-5.3.4.tgz", + "integrity": "sha512-Lj7fLBIMwYFgViVVZHEdExZC3lVYsl+QL0VmdNdIzGZH544jHveYWij6qdnBgJQDnR7pMKliN9z2cPZFEbhyPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/readdir-glob": "*" + } + }, "node_modules/@types/bcrypt": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz", @@ -1599,6 +1611,16 @@ "@types/node": "*" } }, + "node_modules/@types/readdir-glob": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@types/readdir-glob/-/readdir-glob-1.1.5.tgz", + "integrity": "sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/resolve": { "version": "1.20.2", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", diff --git a/package.json b/package.json index d1eb68e..196266a 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "@sveltejs/adapter-node": "^5.5.2", "@sveltejs/kit": "^2.0.0", "@sveltejs/vite-plugin-svelte": "^4.0.0-next.0", + "@types/archiver": "^5.3.4", "@types/bcrypt": "^6.0.0", "@types/jsonwebtoken": "^9.0.10", "@types/mssql": "^9.1.5", @@ -34,6 +35,7 @@ "vitest": "^2.1.9" }, "dependencies": { + "archiver": "^5.3.2", "bcrypt": "^6.0.0", "bootstrap": "^5.3.3", "dotenv": "^16.4.5", diff --git a/src/lib/components/AppShell.svelte b/src/lib/components/AppShell.svelte index 377d3b3..9eab508 100644 --- a/src/lib/components/AppShell.svelte +++ b/src/lib/components/AppShell.svelte @@ -121,12 +121,15 @@ }); type NavItem = { href: string; label: string; icon: string }; - const navGeneral: NavItem[] = [ - { href: '/reportes', label: 'Reportes', icon: 'assessment' } - ]; + // Reportes pasó a Administración: la página /reportes es solo admin (validado también en el + // servidor). navGeneral queda vacío por ahora, pero se conserva por si vuelve a haber ítems + // visibles para todos los roles. + const navGeneral: NavItem[] = []; const navAdmin: NavItem[] = [ + { href: '/reportes', label: 'Reportes', icon: 'assessment' }, { href: '/usuarios', label: 'Gestión de Usuarios', icon: 'manage_accounts' }, { href: '/servidores-restauracion', label: 'Servidores de Restauración', icon: 'storage' }, + { href: '/servidores-restauracion/depuracion', label: 'Depurar Duplicadas', icon: 'cleaning_services' }, { href: '/versiones-cras', label: 'Versiones CRAS', icon: 'system_update' } ]; diff --git a/src/lib/components/Spinner.svelte b/src/lib/components/Spinner.svelte new file mode 100644 index 0000000..b044319 --- /dev/null +++ b/src/lib/components/Spinner.svelte @@ -0,0 +1,34 @@ + + + diff --git a/src/lib/cras-install-progress.test.ts b/src/lib/cras-install-progress.test.ts new file mode 100644 index 0000000..a33bdc0 --- /dev/null +++ b/src/lib/cras-install-progress.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from 'vitest'; +import { + INSTALL_PHASE_STARTING, + INSTALL_STEP_LABEL, + INSTALL_STEP_SEQUENCE, + currentPhaseLabel, + expectedStepCount, + formatElapsed, + progressPercent, + type InstallStepView +} from './cras-install-progress'; + +function step(name: string, ok = true): InstallStepView { + return { step: name, at: '2026-07-29T20:00:00.000Z', ok }; +} + +describe('currentPhaseLabel', () => { + it('sin pasos anuncia la apertura de la instalación', () => { + expect(currentPhaseLabel([])).toBe(INSTALL_PHASE_STARTING); + }); + + it('traduce el último paso a la fase que sigue en curso, no a "terminado"', () => { + // El caso que reportó el operador: `subir-artefacto` se persiste ANTES del fastPut de + // ~270 MB, así que un ✓ en ese paso significa "empezó la subida". + expect(currentPhaseLabel([step('preparar-artefacto'), step('subir-artefacto')])).toBe( + 'Subiendo el artefacto por SFTP…' + ); + expect(currentPhaseLabel([step('preparar-artefacto')])).toBe( + 'Descargando de Gitea y verificando sha256…' + ); + expect(currentPhaseLabel([step('ejecutar-instalador')])).toBe( + 'Corriendo el instalador en el servidor…' + ); + }); + + it('un paso fallido es el desenlace, no una fase en curso', () => { + expect(currentPhaseLabel([step('precondiciones'), step('error', false)])).toBe('Error'); + expect(currentPhaseLabel([step('subir-artefacto', false)])).toBe('Subir artefacto por SFTP'); + }); + + it('un paso desconocido no rompe la fila viva', () => { + expect(currentPhaseLabel([step('paso-nuevo-del-instalador')])).toBe('Trabajando…'); + }); +}); + +describe('expectedStepCount', () => { + it('cuenta verificar-servicio solo en modo servicio', () => { + // `cras-install.ts` corta con `if (autostart !== 'service') return;` antes de ese paso. + expect(expectedStepCount('service')).toBe(11); + expect(expectedStepCount('desktop')).toBe(10); + expect(expectedStepCount('none')).toBe(10); + }); +}); + +describe('progressPercent', () => { + it('nunca llega a 100 mientras el run sigue corriendo', () => { + expect(progressPercent(11, 'service', 'running')).toBe(95); + expect(progressPercent(50, 'service', 'running')).toBe(95); + }); + + it('100 solo cuando el backend cerró el run como completado', () => { + expect(progressPercent(11, 'service', 'completed')).toBe(100); + expect(progressPercent(0, 'service', 'completed')).toBe(100); + }); + + it('escala con los pasos emitidos y el denominador del modo de arranque', () => { + expect(progressPercent(0, 'service', 'running')).toBe(0); + expect(progressPercent(5, 'none', 'running')).toBe(50); + // Mismo conteo de pasos, denominador distinto: 6/11 vs 6/10. + expect(progressPercent(6, 'service', 'running')).toBe(55); + expect(progressPercent(6, 'none', 'running')).toBe(60); + }); + + it('un run fallido conserva el avance parcial en lugar de saltar a 100', () => { + expect(progressPercent(3, 'service', 'failed')).toBe(27); + }); +}); + +describe('formatElapsed', () => { + it('formatea mm:ss y h:mm:ss', () => { + expect(formatElapsed(0)).toBe('00:00'); + expect(formatElapsed(65_000)).toBe('01:05'); + expect(formatElapsed(599_000)).toBe('09:59'); + expect(formatElapsed(3_725_000)).toBe('1:02:05'); + }); + + it('un reloj desfasado no produce tiempos negativos', () => { + expect(formatElapsed(-5_000)).toBe('00:00'); + }); +}); + +describe('cobertura de las etiquetas', () => { + it('todo paso del camino feliz tiene etiqueta y fase siguiente', () => { + for (const name of INSTALL_STEP_SEQUENCE) { + expect(INSTALL_STEP_LABEL[name], `falta etiqueta de ${name}`).toBeTruthy(); + expect(currentPhaseLabel([step(name)]), `falta fase de ${name}`).not.toBe( + 'Trabajando…' + ); + } + }); + + it('los pasos fuera del camino feliz también tienen etiqueta', () => { + expect(INSTALL_STEP_LABEL['limpiar-staging']).toBeTruthy(); + expect(INSTALL_STEP_LABEL.error).toBeTruthy(); + }); +}); diff --git a/src/lib/cras-install-progress.ts b/src/lib/cras-install-progress.ts new file mode 100644 index 0000000..1059a8b --- /dev/null +++ b/src/lib/cras-install-progress.ts @@ -0,0 +1,119 @@ +/** + * Fases de una instalación remota de CloudRestoreAS, para la UI de /versiones-cras. + * + * Módulo PURO (se consume desde el navegador): sin acceso a BD, red ni filesystem. + * + * Los pasos que persiste el instalador (`cras-install.ts`) son marcas de ARRANQUE de cada fase, + * no de terminación: `preparar-artefacto` se escribe antes de bajar el artefacto de Gitea y + * `subir-artefacto` antes del `fastPut` de ~270 MB. La pantalla tiene que traducir "último paso + * emitido" a "esto es lo que está pasando ahora", o una subida en curso se ve idéntica a una + * terminada — que es justo lo que reportó el operador. + */ + +export interface InstallStepView { + step: string; + detail?: string; + at: string; + ok: boolean; +} + +export type AutostartMode = 'service' | 'desktop' | 'none'; +export type RunStatus = 'running' | 'completed' | 'failed'; + +/** + * Orden canónico del camino feliz, idéntico en Linux y Windows (`cras-install.ts`). + * `verificar-servicio` es el único condicionado: solo se emite con arranque en modo servicio. + */ +export const INSTALL_STEP_SEQUENCE = [ + 'preparar-artefacto', + 'artefacto-listo', + 'conexion-ssh', + 'ruta-de-instalacion', + 'precondiciones', + 'subir-artefacto', + 'verificar-sha256', + 'sembrar-configuracion', + 'ejecutar-instalador', + 'verificar-version', + 'verificar-servicio' +] as const; + +/** Etiquetas legibles. Incluye los pasos fuera del camino feliz (`limpiar-staging`, `error`). */ +export const INSTALL_STEP_LABEL: Record = { + 'preparar-artefacto': 'Preparar artefacto', + 'artefacto-listo': 'Artefacto en caché y verificado', + 'conexion-ssh': 'Conexión SSH', + 'ruta-de-instalacion': 'Ruta de instalación', + precondiciones: 'Precondiciones del destino', + 'subir-artefacto': 'Subir artefacto por SFTP', + 'verificar-sha256': 'sha256 verificado en el destino', + 'sembrar-configuracion': 'Sembrar config/.env', + 'ejecutar-instalador': 'Ejecutar instalador', + 'verificar-version': 'Verificar versión desplegada', + 'verificar-servicio': 'Verificar servicio', + 'limpiar-staging': 'Limpiar carpeta temporal', + error: 'Error' +}; + +/** + * Qué está ocurriendo AHORA según el último paso emitido: la fase que arrancó ese paso y que + * todavía no termina. Estas son las esperas largas de una instalación. + */ +const PHASE_IN_PROGRESS: Record = { + 'preparar-artefacto': 'Descargando de Gitea y verificando sha256…', + 'artefacto-listo': 'Abriendo la sesión SSH/SFTP…', + 'conexion-ssh': 'Resolviendo la ruta de instalación…', + 'ruta-de-instalacion': 'Validando sistema, privilegios y herramientas del destino…', + precondiciones: 'Preparando la carpeta temporal del destino…', + 'subir-artefacto': 'Subiendo el artefacto por SFTP…', + 'verificar-sha256': 'Sembrando config/.env en el destino…', + 'sembrar-configuracion': 'Extrayendo el paquete e instalando…', + 'ejecutar-instalador': 'Corriendo el instalador en el servidor…', + 'verificar-version': 'Verificando el servicio…', + 'verificar-servicio': 'Limpiando la carpeta temporal…' +}; + +export const INSTALL_PHASE_STARTING = 'Abriendo la instalación…'; +const INSTALL_PHASE_UNKNOWN = 'Trabajando…'; + +/** Descripción de la fase en curso, para la fila viva del bloque de progreso. */ +export function currentPhaseLabel(steps: InstallStepView[]): string { + const last = steps.at(-1); + if (!last) return INSTALL_PHASE_STARTING; + // Un paso con ok=false ya es el desenlace: no hay fase siguiente que anunciar. + if (!last.ok) return INSTALL_STEP_LABEL[last.step] ?? last.step; + return PHASE_IN_PROGRESS[last.step] ?? INSTALL_PHASE_UNKNOWN; +} + +/** Pasos esperados del camino feliz según el modo de arranque. */ +export function expectedStepCount(autostart: AutostartMode): number { + return autostart === 'service' + ? INSTALL_STEP_SEQUENCE.length + : INSTALL_STEP_SEQUENCE.length - 1; +} + +/** + * Avance 0-100. Se topa en 95 mientras corre: llegar a 100 antes de que el backend cierre el run + * es justo el engaño que se está corrigiendo. + */ +export function progressPercent( + stepCount: number, + autostart: AutostartMode, + status: RunStatus +): number { + if (status === 'completed') return 100; + const total = expectedStepCount(autostart); + const raw = total > 0 ? Math.round((stepCount / total) * 100) : 0; + return Math.min(Math.max(raw, 0), 95); +} + +/** mm:ss, o h:mm:ss cuando pasa de la hora. Los negativos se tratan como 0 (relojes desfasados). */ +export function formatElapsed(ms: number): string { + const totalSeconds = Math.max(0, Math.floor(ms / 1000)); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + const mm = String(minutes).padStart(2, '0'); + const ss = String(seconds).padStart(2, '0'); + return hours > 0 ? `${hours}:${mm}:${ss}` : `${mm}:${ss}`; +} diff --git a/src/lib/cras-version.test.ts b/src/lib/cras-version.test.ts index bd73a52..9941016 100644 --- a/src/lib/cras-version.test.ts +++ b/src/lib/cras-version.test.ts @@ -10,6 +10,7 @@ import { compareVersions, effectiveArch, effectivePlatform, + installPlatformVerdict, isCrasPlatform, isNewer, isValidVersion, @@ -173,3 +174,30 @@ describe('isCrasPlatform y platformLabel', () => { expect(platformLabel(null)).toBe('Sin determinar'); }); }); + +describe('installPlatformVerdict', () => { + it('con plataforma conocida solo deja pasar el artefacto que corresponde', () => { + expect(installPlatformVerdict('linux', 'linux', null)).toBe('ok'); + expect(installPlatformVerdict('windows', 'linux', null)).toBe('mismatch'); + expect(installPlatformVerdict('linux', 'windows', null)).toBe('mismatch'); + }); + + it('una confirmación no puede pasar por encima de una plataforma conocida', () => { + // El destino es Windows y el operador confirma "linux": sigue siendo un desajuste. + expect(installPlatformVerdict('windows', 'linux', 'linux')).toBe('mismatch'); + }); + + it('sin plataforma determinada exige confirmación explícita del operador', () => { + // El defecto que se corrige: el modal preseleccionaba el primer artefacto del catálogo + // (el más recién descubierto, hoy Linux) para un servidor "Sin determinar". + expect(installPlatformVerdict(null, 'linux', null)).toBe('needs-ack'); + expect(installPlatformVerdict(null, 'linux', '')).toBe('needs-ack'); + expect(installPlatformVerdict(undefined, 'windows', undefined)).toBe('needs-ack'); + }); + + it('la confirmación tiene que coincidir con la plataforma del artefacto', () => { + expect(installPlatformVerdict(null, 'linux', 'linux')).toBe('ok'); + expect(installPlatformVerdict(null, 'linux', 'windows')).toBe('needs-ack'); + expect(installPlatformVerdict(null, 'windows', ' WINDOWS ')).toBe('ok'); + }); +}); diff --git a/src/lib/cras-version.ts b/src/lib/cras-version.ts index 172247b..5457ab1 100644 --- a/src/lib/cras-version.ts +++ b/src/lib/cras-version.ts @@ -216,3 +216,25 @@ export function platformLabel(platform: string | null | undefined): string { if (platform === 'linux') return 'Linux'; return 'Sin determinar'; } + +/** + * ¿Se puede instalar un artefacto de `releasePlatform` en un destino cuya plataforma efectiva es + * `targetPlatform`? + * + * Cuando el panel NO pudo determinar la plataforma del destino no se adivina: se exige una + * confirmación explícita del operador (`ack`) que coincida con la plataforma del artefacto. Sin + * esto, el modal preseleccionaba el primer artefacto del catálogo y un solo clic podía mandar el + * .tar.gz de Linux a un Windows; el fallo aparecía hasta el destino, ya con la sesión SSH abierta + * y un run fallido en la bitácora. + * + * La misma función se usa en la UI (para habilitar el botón) y en la action `install` (como red + * de seguridad ante un formulario viejo o un POST a mano), así que la regla vive en un solo lugar. + */ +export function installPlatformVerdict( + targetPlatform: CrasPlatform | null | undefined, + releasePlatform: CrasPlatform, + ack: string | null | undefined +): 'ok' | 'mismatch' | 'needs-ack' { + if (targetPlatform) return targetPlatform === releasePlatform ? 'ok' : 'mismatch'; + return (ack ?? '').trim().toLowerCase() === releasePlatform ? 'ok' : 'needs-ack'; +} diff --git a/src/lib/icons/README.md b/src/lib/icons/README.md new file mode 100644 index 0000000..b70ccae --- /dev/null +++ b/src/lib/icons/README.md @@ -0,0 +1,36 @@ +# Ligaduras de la fuente de iconos + +`material-icons-outlined.codepoints` es la lista **autoritativa** de ligaduras de la fuente que +carga el panel en `src/app.html`: + +```html + +``` + +Es el set **clásico** (Material Icons Outlined, 2195 ligaduras), no Material **Symbols** (4267). +El archivo viene del release oficial `google/material-design-icons`, licencia Apache-2.0, y es el +mismo con el que se genera el `.woff2` que sirve Google: + +``` +font/MaterialIconsOutlined-Regular.codepoints +``` + +## Por qué existe esta lista en el repo + +Cuando el nombre de un icono no existe en la fuente, el navegador **no falla**: renderiza el nombre +como texto literal. Un `progress_activity` (que es de Material Symbols) se pinta como la cadena +"progress_activity" de ~200 px a los 24 px que fija la clase `.material-icons-outlined`, y con +`animate-spin` la hace girar. Eso desbordó el pie del modal de instalación de `/versiones-cras`: +el botón de submit creció, su etiqueta se partió palabra por palabra y el botón "Cerrar" quedó +recortado fuera del panel. + +Ni `tsc --noEmit` ni el build detectan esto — no es un error de tipos ni de sintaxis. La única +compuerta es `material-icons.test.ts`, que valida contra esta lista todas las ligaduras usadas en +`src/`. + +## Si se cambia la fuente + +Si algún día se migra a Material Symbols (`app.html` + la clase en las plantillas), hay que +reemplazar este archivo por `MaterialSymbolsOutlined[FILL,GRAD,opsz,wght].codepoints` del mismo +release. El test tiene una aserción que falla a propósito si la lista se cambia sin migrar +`app.html`, para que las dos cosas no se desincronicen. diff --git a/src/lib/icons/material-icons-outlined.codepoints b/src/lib/icons/material-icons-outlined.codepoints new file mode 100644 index 0000000..df7be3b --- /dev/null +++ b/src/lib/icons/material-icons-outlined.codepoints @@ -0,0 +1,2195 @@ +10k e951 +10mp e952 +11mp e953 +123 eb8d +12mp e954 +13mp e955 +14mp e956 +15mp e957 +16mp e958 +17mp e959 +18_up_rating f8fd +18mp e95a +19mp e95b +1k e95c +1k_plus e95d +1x_mobiledata efcd +20mp e95e +21mp e95f +22mp e960 +23mp e961 +24mp e962 +2k e963 +2k_plus e964 +2mp e965 +30fps efce +30fps_select efcf +360 e577 +3d_rotation e84d +3g_mobiledata efd0 +3k e966 +3k_plus e967 +3mp e968 +3p efd1 +4g_mobiledata efd2 +4g_plus_mobiledata efd3 +4k e072 +4k_plus e969 +4mp e96a +5g ef38 +5k e96b +5k_plus e96c +5mp e96d +60fps efd4 +60fps_select efd5 +6_ft_apart f21e +6k e96e +6k_plus e96f +6mp e970 +7k e971 +7k_plus e972 +7mp e973 +8k e974 +8k_plus e975 +8mp e976 +9k e977 +9k_plus e978 +9mp e979 +abc eb94 +ac_unit eb3b +access_alarm e190 +access_alarms e191 +access_time e192 +access_time_filled efd6 +accessibility e84e +accessibility_new e92c +accessible e914 +accessible_forward e934 +account_balance e84f +account_balance_wallet e850 +account_box e851 +account_circle e853 +account_tree e97a +ad_units ef39 +adb e60e +add e145 +add_a_photo e439 +add_alarm e193 +add_alert e003 +add_box e146 +add_business e729 +add_card eb86 +add_chart e97b +add_circle e147 +add_circle_outline e148 +add_comment e266 +add_home f8eb +add_home_work f8ed +add_ic_call e97c +add_link e178 +add_location e567 +add_location_alt ef3a +add_moderator e97d +add_photo_alternate e43e +add_reaction e1d3 +add_road ef3b +add_shopping_cart e854 +add_task f23a +add_to_drive e65c +add_to_home_screen e1fe +add_to_photos e39d +add_to_queue e05c +addchart ef3c +adf_scanner eada +adjust e39e +admin_panel_settings ef3d +adobe ea96 +ads_click e762 +agriculture ea79 +air efd8 +airline_seat_flat e630 +airline_seat_flat_angled e631 +airline_seat_individual_suite e632 +airline_seat_legroom_extra e633 +airline_seat_legroom_normal e634 +airline_seat_legroom_reduced e635 +airline_seat_recline_extra e636 +airline_seat_recline_normal e637 +airline_stops e7d0 +airlines e7ca +airplane_ticket efd9 +airplanemode_active e195 +airplanemode_inactive e194 +airplanemode_off e194 +airplanemode_on e195 +airplay e055 +airport_shuttle eb3c +alarm e855 +alarm_add e856 +alarm_off e857 +alarm_on e858 +album e019 +align_horizontal_center e00f +align_horizontal_left e00d +align_horizontal_right e010 +align_vertical_bottom e015 +align_vertical_center e011 +align_vertical_top e00c +all_inbox e97f +all_inclusive eb3d +all_out e90b +alt_route f184 +alternate_email e0e6 +amp_stories ea13 +analytics ef3e +anchor f1cd +android e859 +animation e71c +announcement e85a +aod efda +apartment ea40 +api f1b7 +app_blocking ef3f +app_registration ef40 +app_settings_alt ef41 +app_shortcut eae4 +apple ea80 +approval e982 +apps e5c3 +apps_outage e7cc +architecture ea3b +archive e149 +area_chart e770 +arrow_back e5c4 +arrow_back_ios e5e0 +arrow_back_ios_new e2ea +arrow_circle_down f181 +arrow_circle_left eaa7 +arrow_circle_right eaaa +arrow_circle_up f182 +arrow_downward e5db +arrow_drop_down e5c5 +arrow_drop_down_circle e5c6 +arrow_drop_up e5c7 +arrow_forward e5c8 +arrow_forward_ios e5e1 +arrow_left e5de +arrow_outward f8ce +arrow_right e5df +arrow_right_alt e941 +arrow_upward e5d8 +art_track e060 +article ef42 +aspect_ratio e85b +assessment e85c +assignment e85d +assignment_ind e85e +assignment_late e85f +assignment_return e860 +assignment_returned e861 +assignment_turned_in e862 +assist_walker f8d5 +assistant e39f +assistant_direction e988 +assistant_photo e3a0 +assured_workload eb6f +atm e573 +attach_email ea5e +attach_file e226 +attach_money e227 +attachment e2bc +attractions ea52 +attribution efdb +audio_file eb82 +audiotrack e3a1 +auto_awesome e65f +auto_awesome_mosaic e660 +auto_awesome_motion e661 +auto_delete ea4c +auto_fix_high e663 +auto_fix_normal e664 +auto_fix_off e665 +auto_graph e4fb +auto_mode ec20 +auto_stories e666 +autofps_select efdc +autorenew e863 +av_timer e01b +baby_changing_station f19b +back_hand e764 +backpack f19c +backspace e14a +backup e864 +backup_table ef43 +badge ea67 +bakery_dining ea53 +balance eaf6 +balcony e58f +ballot e172 +bar_chart e26b +batch_prediction f0f5 +bathroom efdd +bathtub ea41 +battery_0_bar ebdc +battery_1_bar ebd9 +battery_2_bar ebe0 +battery_3_bar ebdd +battery_4_bar ebe2 +battery_5_bar ebd4 +battery_6_bar ebd2 +battery_alert e19c +battery_charging_full e1a3 +battery_full e1a4 +battery_saver efde +battery_std e1a5 +battery_unknown e1a6 +beach_access eb3e +bed efdf +bedroom_baby efe0 +bedroom_child efe1 +bedroom_parent efe2 +bedtime ef44 +bedtime_off eb76 +beenhere e52d +bento f1f4 +bike_scooter ef45 +biotech ea3a +blender efe3 +blind f8d6 +blinds e286 +blinds_closed ec1f +block e14b +bloodtype efe4 +bluetooth e1a7 +bluetooth_audio e60f +bluetooth_connected e1a8 +bluetooth_disabled e1a9 +bluetooth_drive efe5 +bluetooth_searching e1aa +blur_circular e3a2 +blur_linear e3a3 +blur_off e3a4 +blur_on e3a5 +bolt ea0b +book e865 +book_online f217 +bookmark e866 +bookmark_add e598 +bookmark_added e599 +bookmark_border e867 +bookmark_outline e867 +bookmark_remove e59a +bookmarks e98b +border_all e228 +border_bottom e229 +border_clear e22a +border_color e22b +border_horizontal e22c +border_inner e22d +border_left e22e +border_outer e22f +border_right e230 +border_style e231 +border_top e232 +border_vertical e233 +boy eb67 +branding_watermark e06b +breakfast_dining ea54 +brightness_1 e3a6 +brightness_2 e3a7 +brightness_3 e3a8 +brightness_4 e3a9 +brightness_5 e3aa +brightness_6 e3ab +brightness_7 e3ac +brightness_auto e1ab +brightness_high e1ac +brightness_low e1ad +brightness_medium e1ae +broadcast_on_home f8f8 +broadcast_on_personal f8f9 +broken_image e3ad +browse_gallery ebd1 +browser_not_supported ef47 +browser_updated e7cf +brunch_dining ea73 +brush e3ae +bubble_chart e6dd +bug_report e868 +build e869 +build_circle ef48 +bungalow e591 +burst_mode e43c +bus_alert e98f +business e0af +business_center eb3f +cabin e589 +cable efe6 +cached e86a +cake e7e9 +calculate ea5f +calendar_month ebcc +calendar_today e935 +calendar_view_day e936 +calendar_view_month efe7 +calendar_view_week efe8 +call e0b0 +call_end e0b1 +call_made e0b2 +call_merge e0b3 +call_missed e0b4 +call_missed_outgoing e0e4 +call_received e0b5 +call_split e0b6 +call_to_action e06c +camera e3af +camera_alt e3b0 +camera_enhance e8fc +camera_front e3b1 +camera_indoor efe9 +camera_outdoor efea +camera_rear e3b2 +camera_roll e3b3 +cameraswitch efeb +campaign ef49 +cancel e5c9 +cancel_presentation e0e9 +cancel_schedule_send ea39 +candlestick_chart ead4 +car_crash ebf2 +car_rental ea55 +car_repair ea56 +card_giftcard e8f6 +card_membership e8f7 +card_travel e8f8 +carpenter f1f8 +cases e992 +casino eb40 +cast e307 +cast_connected e308 +cast_for_education efec +castle eab1 +catching_pokemon e508 +category e574 +celebration ea65 +cell_tower ebba +cell_wifi e0ec +center_focus_strong e3b4 +center_focus_weak e3b5 +chair efed +chair_alt efee +chalet e585 +change_circle e2e7 +change_history e86b +charging_station f19d +chat e0b7 +chat_bubble e0ca +chat_bubble_outline e0cb +check e5ca +check_box e834 +check_box_outline_blank e835 +check_circle e86c +check_circle_outline e92d +checklist e6b1 +checklist_rtl e6b3 +checkroom f19e +chevron_left e5cb +chevron_right e5cc +child_care eb41 +child_friendly eb42 +chrome_reader_mode e86d +church eaae +circle ef4a +circle_notifications e994 +class e86e +clean_hands f21f +cleaning_services f0ff +clear e14c +clear_all e0b8 +close e5cd +close_fullscreen f1cf +closed_caption e01c +closed_caption_disabled f1dc +closed_caption_off e996 +cloud e2bd +cloud_circle e2be +cloud_done e2bf +cloud_download e2c0 +cloud_off e2c1 +cloud_queue e2c2 +cloud_sync eb5a +cloud_upload e2c3 +co2 e7b0 +co_present eaf0 +code e86f +code_off e4f3 +coffee efef +coffee_maker eff0 +collections e3b6 +collections_bookmark e431 +color_lens e3b7 +colorize e3b8 +comment e0b9 +comment_bank ea4e +comments_disabled e7a2 +commit eaf5 +commute e940 +compare e3b9 +compare_arrows e915 +compass_calibration e57c +compost e761 +compress e94d +computer e30a +confirmation_num e638 +confirmation_number e638 +connect_without_contact f223 +connected_tv e998 +connecting_airports e7c9 +construction ea3c +contact_emergency f8d1 +contact_mail e0d0 +contact_page f22e +contact_phone e0cf +contact_support e94c +contactless ea71 +contacts e0ba +content_copy f08a +content_cut f08b +content_paste f098 +content_paste_go ea8e +content_paste_off e4f8 +content_paste_search ea9b +contrast eb37 +control_camera e074 +control_point e3ba +control_point_duplicate e3bb +cookie eaac +copy f08a +copy_all e2ec +copyright e90c +coronavirus f221 +corporate_fare f1d0 +cottage e587 +countertops f1f7 +create e150 +create_new_folder e2cc +credit_card e870 +credit_card_off e4f4 +credit_score eff1 +crib e588 +crisis_alert ebe9 +crop e3be +crop_16_9 e3bc +crop_3_2 e3bd +crop_5_4 e3bf +crop_7_5 e3c0 +crop_din e3c1 +crop_free e3c2 +crop_landscape e3c3 +crop_original e3c4 +crop_portrait e3c5 +crop_rotate e437 +crop_square e3c6 +cruelty_free e799 +css eb93 +currency_bitcoin ebc5 +currency_exchange eb70 +currency_franc eafa +currency_lira eaef +currency_pound eaf1 +currency_ruble eaec +currency_rupee eaf7 +currency_yen eafb +currency_yuan eaf9 +curtains ec1e +curtains_closed ec1d +cut f08b +cyclone ebd5 +dangerous e99a +dark_mode e51c +dashboard e871 +dashboard_customize e99b +data_array ead1 +data_exploration e76f +data_object ead3 +data_saver_off eff2 +data_saver_on eff3 +data_thresholding eb9f +data_usage e1af +dataset f8ee +dataset_linked f8ef +date_range e916 +deblur eb77 +deck ea42 +dehaze e3c7 +delete e872 +delete_forever e92b +delete_outline e92e +delete_sweep e16c +delivery_dining ea72 +density_large eba9 +density_medium eb9e +density_small eba8 +departure_board e576 +description e873 +deselect ebb6 +design_services f10a +desk f8f4 +desktop_access_disabled e99d +desktop_mac e30b +desktop_windows e30c +details e3c8 +developer_board e30d +developer_board_off e4ff +developer_mode e1b0 +device_hub e335 +device_thermostat e1ff +device_unknown e339 +devices e1b1 +devices_fold ebde +devices_other e337 +dialer_sip e0bb +dialpad e0bc +diamond ead5 +difference eb7d +dining eff4 +dinner_dining ea57 +directions e52e +directions_bike e52f +directions_boat e532 +directions_boat_filled eff5 +directions_bus e530 +directions_bus_filled eff6 +directions_car e531 +directions_car_filled eff7 +directions_ferry e532 +directions_off f10f +directions_railway e534 +directions_railway_filled eff8 +directions_run e566 +directions_subway e533 +directions_subway_filled eff9 +directions_train e534 +directions_transit e535 +directions_transit_filled effa +directions_walk e536 +dirty_lens ef4b +disabled_by_default f230 +disabled_visible e76e +disc_full e610 +discord ea6c +discount ebc9 +display_settings eb97 +diversity_1 f8d7 +diversity_2 f8d8 +diversity_3 f8d9 +dnd_forwardslash e611 +dns e875 +do_disturb f08c +do_disturb_alt f08d +do_disturb_off f08e +do_disturb_on f08f +do_not_disturb e612 +do_not_disturb_alt e611 +do_not_disturb_off e643 +do_not_disturb_on e644 +do_not_disturb_on_total_silence effb +do_not_step f19f +do_not_touch f1b0 +dock e30e +document_scanner e5fa +domain e7ee +domain_add eb62 +domain_disabled e0ef +domain_verification ef4c +done e876 +done_all e877 +done_outline e92f +donut_large e917 +donut_small e918 +door_back effc +door_front effd +door_sliding effe +doorbell efff +double_arrow ea50 +downhill_skiing e509 +download f090 +download_done f091 +download_for_offline f000 +downloading f001 +drafts e151 +drag_handle e25d +drag_indicator e945 +draw e746 +drive_eta e613 +drive_file_move e675 +drive_file_move_rtl e76d +drive_file_rename_outline e9a2 +drive_folder_upload e9a3 +dry f1b3 +dry_cleaning ea58 +duo e9a5 +dvr e1b2 +dynamic_feed ea14 +dynamic_form f1bf +e_mobiledata f002 +earbuds f003 +earbuds_battery f004 +east f1df +eco ea35 +edgesensor_high f005 +edgesensor_low f006 +edit e3c9 +edit_attributes e578 +edit_calendar e742 +edit_location e568 +edit_location_alt e1c5 +edit_note e745 +edit_notifications e525 +edit_off e950 +edit_road ef4d +egg eacc +egg_alt eac8 +eject e8fb +elderly f21a +elderly_woman eb69 +electric_bike eb1b +electric_bolt ec1c +electric_car eb1c +electric_meter ec1b +electric_moped eb1d +electric_rickshaw eb1e +electric_scooter eb1f +electrical_services f102 +elevator f1a0 +email e0be +emergency e1eb +emergency_recording ebf4 +emergency_share ebf6 +emoji_emotions ea22 +emoji_events ea23 +emoji_flags ea1a +emoji_food_beverage ea1b +emoji_nature ea1c +emoji_objects ea24 +emoji_people ea1d +emoji_symbols ea1e +emoji_transportation ea1f +energy_savings_leaf ec1a +engineering ea3d +enhance_photo_translate e8fc +enhanced_encryption e63f +equalizer e01d +error e000 +error_outline e001 +escalator f1a1 +escalator_warning f1ac +euro ea15 +euro_symbol e926 +ev_station e56d +event e878 +event_available e614 +event_busy e615 +event_note e616 +event_repeat eb7b +event_seat e903 +exit_to_app e879 +expand e94f +expand_circle_down e7cd +expand_less e5ce +expand_more e5cf +explicit e01e +explore e87a +explore_off e9a8 +exposure e3ca +exposure_minus_1 e3cb +exposure_minus_2 e3cc +exposure_neg_1 e3cb +exposure_neg_2 e3cc +exposure_plus_1 e3cd +exposure_plus_2 e3ce +exposure_zero e3cf +extension e87b +extension_off e4f5 +face e87c +face_2 f8da +face_3 f8db +face_4 f8dc +face_5 f8dd +face_6 f8de +face_retouching_natural ef4e +face_retouching_off f007 +face_unlock f008 +facebook f234 +fact_check f0c5 +factory ebbc +family_restroom f1a2 +fast_forward e01f +fast_rewind e020 +fastfood e57a +favorite e87d +favorite_border e87e +favorite_outline e87e +fax ead8 +featured_play_list e06d +featured_video e06e +feed f009 +feedback e87f +female e590 +fence f1f6 +festival ea68 +fiber_dvr e05d +fiber_manual_record e061 +fiber_new e05e +fiber_pin e06a +fiber_smart_record e062 +file_copy e173 +file_download e2c4 +file_download_done e9aa +file_download_off e4fe +file_open eaf3 +file_present ea0e +file_upload e2c6 +filter e3d3 +filter_1 e3d0 +filter_2 e3d1 +filter_3 e3d2 +filter_4 e3d4 +filter_5 e3d5 +filter_6 e3d6 +filter_7 e3d7 +filter_8 e3d8 +filter_9 e3d9 +filter_9_plus e3da +filter_alt ef4f +filter_alt_off eb32 +filter_b_and_w e3db +filter_center_focus e3dc +filter_drama e3dd +filter_frames e3de +filter_hdr e3df +filter_list e152 +filter_list_off eb57 +filter_none e3e0 +filter_tilt_shift e3e2 +filter_vintage e3e3 +find_in_page e880 +find_replace e881 +fingerprint e90d +fire_extinguisher f1d8 +fire_hydrant_alt f8f1 +fire_truck f8f2 +fireplace ea43 +first_page e5dc +fit_screen ea10 +fitbit e82b +fitness_center eb43 +flag e153 +flag_circle eaf8 +flaky ef50 +flare e3e4 +flash_auto e3e5 +flash_off e3e6 +flash_on e3e7 +flashlight_off f00a +flashlight_on f00b +flatware f00c +flight e539 +flight_class e7cb +flight_land e904 +flight_takeoff e905 +flip e3e8 +flip_camera_android ea37 +flip_camera_ios ea38 +flip_to_back e882 +flip_to_front e883 +flood ebe6 +flourescent f00d +fluorescent f00d +flutter_dash e00b +fmd_bad f00e +fmd_good f00f +folder e2c7 +folder_copy ebbd +folder_delete eb34 +folder_off eb83 +folder_open e2c8 +folder_shared e2c9 +folder_special e617 +folder_zip eb2c +follow_the_signs f222 +font_download e167 +font_download_off e4f9 +food_bank f1f2 +forest ea99 +fork_left eba0 +fork_right ebac +format_align_center e234 +format_align_justify e235 +format_align_left e236 +format_align_right e237 +format_bold e238 +format_clear e239 +format_color_fill e23a +format_color_reset e23b +format_color_text e23c +format_indent_decrease e23d +format_indent_increase e23e +format_italic e23f +format_line_spacing e240 +format_list_bulleted e241 +format_list_numbered e242 +format_list_numbered_rtl e267 +format_overline eb65 +format_paint e243 +format_quote e244 +format_shapes e25e +format_size e245 +format_strikethrough e246 +format_textdirection_l_to_r e247 +format_textdirection_r_to_l e248 +format_underline e765 +format_underlined e765 +fort eaad +forum e0bf +forward e154 +forward_10 e056 +forward_30 e057 +forward_5 e058 +forward_to_inbox f187 +foundation f200 +free_breakfast eb44 +free_cancellation e748 +front_hand e769 +fullscreen e5d0 +fullscreen_exit e5d1 +functions e24a +g_mobiledata f010 +g_translate e927 +gamepad e30f +games e021 +garage f011 +gas_meter ec19 +gavel e90e +generating_tokens e749 +gesture e155 +get_app e884 +gif e908 +gif_box e7a3 +girl eb68 +gite e58b +golf_course eb45 +gpp_bad f012 +gpp_good f013 +gpp_maybe f014 +gps_fixed e1b3 +gps_not_fixed e1b4 +gps_off e1b5 +grade e885 +gradient e3e9 +grading ea4f +grain e3ea +graphic_eq e1b8 +grass f205 +grid_3x3 f015 +grid_4x4 f016 +grid_goldenratio f017 +grid_off e3eb +grid_on e3ec +grid_view e9b0 +group e7ef +group_add e7f0 +group_off e747 +group_remove e7ad +group_work e886 +groups f233 +groups_2 f8df +groups_3 f8e0 +h_mobiledata f018 +h_plus_mobiledata f019 +hail e9b1 +handshake ebcb +handyman f10b +hardware ea59 +hd e052 +hdr_auto f01a +hdr_auto_select f01b +hdr_enhanced_select ef51 +hdr_off e3ed +hdr_off_select f01c +hdr_on e3ee +hdr_on_select f01d +hdr_plus f01e +hdr_strong e3f1 +hdr_weak e3f2 +headphones f01f +headphones_battery f020 +headset e310 +headset_mic e311 +headset_off e33a +healing e3f3 +health_and_safety e1d5 +hearing e023 +hearing_disabled f104 +heart_broken eac2 +heat_pump ec18 +height ea16 +help e887 +help_center f1c0 +help_outline e8fd +hevc f021 +hexagon eb39 +hide_image f022 +hide_source f023 +high_quality e024 +highlight e25f +highlight_alt ef52 +highlight_off e888 +highlight_remove e888 +hiking e50a +history e889 +history_edu ea3e +history_toggle_off f17d +hive eaa6 +hls eb8a +hls_off eb8c +holiday_village e58a +home e88a +home_max f024 +home_mini f025 +home_repair_service f100 +home_work ea09 +horizontal_distribute e014 +horizontal_rule f108 +horizontal_split e947 +hot_tub eb46 +hotel e53a +hotel_class e743 +hourglass_bottom ea5c +hourglass_disabled ef53 +hourglass_empty e88b +hourglass_full e88c +hourglass_top ea5b +house ea44 +house_siding f202 +houseboat e584 +how_to_reg e174 +how_to_vote e175 +html eb7e +http e902 +https e88d +hub e9f4 +hvac f10e +ice_skating e50b +icecream ea69 +image e3f4 +image_aspect_ratio e3f5 +image_not_supported f116 +image_search e43f +imagesearch_roller e9b4 +import_contacts e0e0 +import_export e0c3 +important_devices e912 +inbox e156 +incomplete_circle e79b +indeterminate_check_box e909 +info e88e +input e890 +insert_chart e24b +insert_chart_outlined e26a +insert_comment e24c +insert_drive_file e24d +insert_emoticon e24e +insert_invitation e24f +insert_link e250 +insert_page_break eaca +insert_photo e251 +insights f092 +install_desktop eb71 +install_mobile eb72 +integration_instructions ef54 +interests e7c8 +interpreter_mode e83b +inventory e179 +inventory_2 e1a1 +invert_colors e891 +invert_colors_off e0c4 +invert_colors_on e891 +ios_share e6b8 +iron e583 +iso e3f6 +javascript eb7c +join_full eaeb +join_inner eaf4 +join_left eaf2 +join_right eaea +kayaking e50c +kebab_dining e842 +key e73c +key_off eb84 +keyboard e312 +keyboard_alt f028 +keyboard_arrow_down e313 +keyboard_arrow_left e314 +keyboard_arrow_right e315 +keyboard_arrow_up e316 +keyboard_backspace e317 +keyboard_capslock e318 +keyboard_command_key eae7 +keyboard_control eae1 +keyboard_control_key eae6 +keyboard_double_arrow_down ead0 +keyboard_double_arrow_left eac3 +keyboard_double_arrow_right eac9 +keyboard_double_arrow_up eacf +keyboard_hide e31a +keyboard_option_key eae8 +keyboard_return e31b +keyboard_tab e31c +keyboard_voice e31d +king_bed ea45 +kitchen eb47 +kitesurfing e50d +label e892 +label_important e937 +label_off e9b6 +lan eb2f +landscape e3f7 +landslide ebd7 +language e894 +laptop e31e +laptop_chromebook e31f +laptop_mac e320 +laptop_windows e321 +last_page e5dd +launch e895 +layers e53b +layers_clear e53c +leaderboard f20c +leak_add e3f8 +leak_remove e3f9 +leave_bags_at_home f23b +legend_toggle f11b +lens e3fa +lens_blur f029 +library_add e02e +library_add_check e9b7 +library_books e02f +library_music e030 +light f02a +light_mode e518 +lightbulb e0f0 +lightbulb_circle ebfe +line_axis ea9a +line_style e919 +line_weight e91a +linear_scale e260 +link e157 +link_off e16f +linked_camera e438 +liquor ea60 +list e896 +list_alt e0ee +live_help e0c6 +live_tv e639 +living f02b +local_activity e53f +local_airport e53d +local_atm e53e +local_attraction e53f +local_bar e540 +local_cafe e541 +local_car_wash e542 +local_convenience_store e543 +local_dining e556 +local_drink e544 +local_fire_department ef55 +local_florist e545 +local_gas_station e546 +local_grocery_store e547 +local_hospital e548 +local_hotel e549 +local_laundry_service e54a +local_library e54b +local_mall e54c +local_movies e54d +local_offer e54e +local_parking e54f +local_pharmacy e550 +local_phone e551 +local_pizza e552 +local_play e553 +local_police ef56 +local_post_office e554 +local_print_shop e555 +local_printshop e555 +local_restaurant e556 +local_see e557 +local_shipping e558 +local_taxi e559 +location_city e7f1 +location_disabled e1b6 +location_history e55a +location_off e0c7 +location_on e0c8 +location_searching e1b7 +lock e897 +lock_clock ef57 +lock_open e898 +lock_person f8f3 +lock_reset eade +login ea77 +logo_dev ead6 +logout e9ba +looks e3fc +looks_3 e3fb +looks_4 e3fd +looks_5 e3fe +looks_6 e3ff +looks_one e400 +looks_two e401 +loop e028 +loupe e402 +low_priority e16d +loyalty e89a +lte_mobiledata f02c +lte_plus_mobiledata f02d +luggage f235 +lunch_dining ea61 +lyrics ec0b +macro_off f8d2 +mail e158 +mail_lock ec0a +mail_outline e0e1 +male e58e +man e4eb +man_2 f8e1 +man_3 f8e2 +man_4 f8e3 +manage_accounts f02e +manage_history ebe7 +manage_search f02f +map e55b +maps_home_work f030 +maps_ugc ef58 +margin e9bb +mark_as_unread e9bc +mark_chat_read f18b +mark_chat_unread f189 +mark_email_read f18c +mark_email_unread f18a +mark_unread_chat_alt eb9d +markunread e159 +markunread_mailbox e89b +masks f218 +maximize e930 +media_bluetooth_off f031 +media_bluetooth_on f032 +mediation efa7 +medical_information ebed +medical_services f109 +medication f033 +medication_liquid ea87 +meeting_room eb4f +memory e322 +menu e5d2 +menu_book ea19 +menu_open e9bd +merge eb98 +merge_type e252 +message e0c9 +messenger e0ca +messenger_outline e0cb +mic e029 +mic_external_off ef59 +mic_external_on ef5a +mic_none e02a +mic_off e02b +microwave f204 +military_tech ea3f +minimize e931 +minor_crash ebf1 +miscellaneous_services f10c +missed_video_call e073 +mms e618 +mobile_friendly e200 +mobile_off e201 +mobile_screen_share e0e7 +mobiledata_off f034 +mode f097 +mode_comment e253 +mode_edit e254 +mode_edit_outline f035 +mode_fan_off ec17 +mode_night f036 +mode_of_travel e7ce +mode_standby f037 +model_training f0cf +monetization_on e263 +money e57d +money_off e25c +money_off_csred f038 +monitor ef5b +monitor_heart eaa2 +monitor_weight f039 +monochrome_photos e403 +mood e7f2 +mood_bad e7f3 +moped eb28 +more e619 +more_horiz eae1 +more_time ea5d +more_vert e5d4 +mosque eab2 +motion_photos_auto f03a +motion_photos_off e9c0 +motion_photos_on e9c1 +motion_photos_pause f227 +motion_photos_paused e9c2 +motorcycle e91b +mouse e323 +move_down eb61 +move_to_inbox e168 +move_up eb64 +movie e02c +movie_creation e404 +movie_filter e43a +moving e501 +mp e9c3 +multiline_chart e6df +multiple_stop f1b9 +multitrack_audio e1b8 +museum ea36 +music_note e405 +music_off e440 +music_video e063 +my_library_add e02e +my_library_books e02f +my_library_music e030 +my_location e55c +nat ef5c +nature e406 +nature_people e407 +navigate_before e408 +navigate_next e409 +navigation e55d +near_me e569 +near_me_disabled f1ef +nearby_error f03b +nearby_off f03c +nest_cam_wired_stand ec16 +network_cell e1b9 +network_check e640 +network_locked e61a +network_ping ebca +network_wifi e1ba +network_wifi_1_bar ebe4 +network_wifi_2_bar ebd6 +network_wifi_3_bar ebe1 +new_label e609 +new_releases e031 +newspaper eb81 +next_plan ef5d +next_week e16a +nfc e1bb +night_shelter f1f1 +nightlife ea62 +nightlight f03d +nightlight_round ef5e +nights_stay ea46 +no_accounts f03e +no_adult_content f8fe +no_backpack f237 +no_cell f1a4 +no_crash ebf0 +no_drinks f1a5 +no_encryption e641 +no_encryption_gmailerrorred f03f +no_flash f1a6 +no_food f1a7 +no_luggage f23b +no_meals f1d6 +no_meeting_room eb4e +no_photography f1a8 +no_sim e0cc +no_stroller f1af +no_transfer f1d5 +noise_aware ebec +noise_control_off ebf3 +nordic_walking e50e +north f1e0 +north_east f1e1 +north_west f1e2 +not_accessible f0fe +not_interested e033 +not_listed_location e575 +not_started f0d1 +note e06f +note_add e89c +note_alt f040 +notes e26c +notification_add e399 +notification_important e004 +notifications e7f4 +notifications_active e7f7 +notifications_none e7f5 +notifications_off e7f6 +notifications_on e7f7 +notifications_paused e7f8 +now_wallpaper e75f +now_widgets e75e +numbers eac7 +offline_bolt e932 +offline_pin e90a +offline_share e9c5 +oil_barrel ec15 +on_device_training ebfd +ondemand_video e63a +online_prediction f0eb +opacity e91c +open_in_browser e89d +open_in_full f1ce +open_in_new e89e +open_in_new_off e4f6 +open_with e89f +other_houses e58c +outbond f228 +outbound e1ca +outbox ef5f +outdoor_grill ea47 +outlet f1d4 +outlined_flag e16e +output ebbe +padding e9c8 +pages e7f9 +pageview e8a0 +paid f041 +palette e40a +pan_tool e925 +pan_tool_alt ebb9 +panorama e40b +panorama_fish_eye e40c +panorama_fisheye e40c +panorama_horizontal e40d +panorama_horizontal_select ef60 +panorama_photosphere e9c9 +panorama_photosphere_select e9ca +panorama_vertical e40e +panorama_vertical_select ef61 +panorama_wide_angle e40f +panorama_wide_angle_select ef62 +paragliding e50f +park ea63 +party_mode e7fa +password f042 +paste f098 +pattern f043 +pause e034 +pause_circle e1a2 +pause_circle_filled e035 +pause_circle_outline e036 +pause_presentation e0ea +payment e8a1 +payments ef63 +paypal ea8d +pedal_bike eb29 +pending ef64 +pending_actions f1bb +pentagon eb50 +people e7fb +people_alt ea21 +people_outline e7fc +percent eb58 +perm_camera_mic e8a2 +perm_contact_cal e8a3 +perm_contact_calendar e8a3 +perm_data_setting e8a4 +perm_device_info e8a5 +perm_device_information e8a5 +perm_identity e8a6 +perm_media e8a7 +perm_phone_msg e8a8 +perm_scan_wifi e8a9 +person e7fd +person_2 f8e4 +person_3 f8e5 +person_4 f8e6 +person_add e7fe +person_add_alt ea4d +person_add_alt_1 ef65 +person_add_disabled e9cb +person_off e510 +person_outline e7ff +person_pin e55a +person_pin_circle e56a +person_remove ef66 +person_remove_alt_1 ef67 +person_search f106 +personal_injury e6da +personal_video e63b +pest_control f0fa +pest_control_rodent f0fd +pets e91d +phishing ead7 +phone e0cd +phone_android e324 +phone_bluetooth_speaker e61b +phone_callback e649 +phone_disabled e9cc +phone_enabled e9cd +phone_forwarded e61c +phone_in_talk e61d +phone_iphone e325 +phone_locked e61e +phone_missed e61f +phone_paused e620 +phonelink e326 +phonelink_erase e0db +phonelink_lock e0dc +phonelink_off e327 +phonelink_ring e0dd +phonelink_setup e0de +photo e410 +photo_album e411 +photo_camera e412 +photo_camera_back ef68 +photo_camera_front ef69 +photo_filter e43b +photo_library e413 +photo_size_select_actual e432 +photo_size_select_large e433 +photo_size_select_small e434 +php eb8f +piano e521 +piano_off e520 +picture_as_pdf e415 +picture_in_picture e8aa +picture_in_picture_alt e911 +pie_chart e6c4 +pie_chart_outline f044 +pin f045 +pin_drop e55e +pin_end e767 +pin_invoke e763 +pinch eb38 +pivot_table_chart e9ce +pix eaa3 +place e55f +plagiarism ea5a +play_arrow e037 +play_circle e1c4 +play_circle_fill e038 +play_circle_filled e038 +play_circle_outline e039 +play_disabled ef6a +play_for_work e906 +play_lesson f047 +playlist_add e03b +playlist_add_check e065 +playlist_add_check_circle e7e6 +playlist_add_circle e7e5 +playlist_play e05f +playlist_remove eb80 +plumbing f107 +plus_one e800 +podcasts f048 +point_of_sale f17e +policy ea17 +poll e801 +polyline ebbb +polymer e8ab +pool eb48 +portable_wifi_off e0ce +portrait e416 +post_add ea20 +power e63c +power_input e336 +power_off e646 +power_settings_new e8ac +precision_manufacturing f049 +pregnant_woman e91e +present_to_all e0df +preview f1c5 +price_change f04a +price_check f04b +print e8ad +print_disabled e9cf +priority_high e645 +privacy_tip f0dc +private_connectivity e744 +production_quantity_limits e1d1 +propane ec14 +propane_tank ec13 +psychology ea4a +psychology_alt f8ea +public e80b +public_off f1ca +publish e255 +published_with_changes f232 +punch_clock eaa8 +push_pin f10d +qr_code ef6b +qr_code_2 e00a +qr_code_scanner f206 +query_builder e8ae +query_stats e4fc +question_answer e8af +question_mark eb8b +queue e03c +queue_music e03d +queue_play_next e066 +quick_contacts_dialer e0cf +quick_contacts_mail e0d0 +quickreply ef6c +quiz f04c +quora ea98 +r_mobiledata f04d +radar f04e +radio e03e +radio_button_checked e837 +radio_button_off e836 +radio_button_on e837 +radio_button_unchecked e836 +railway_alert e9d1 +ramen_dining ea64 +ramp_left eb9c +ramp_right eb96 +rate_review e560 +raw_off f04f +raw_on f050 +read_more ef6d +real_estate_agent e73a +receipt e8b0 +receipt_long ef6e +recent_actors e03f +recommend e9d2 +record_voice_over e91f +rectangle eb54 +recycling e760 +reddit eaa0 +redeem e8b1 +redo e15a +reduce_capacity f21c +refresh e5d5 +remember_me f051 +remove e15b +remove_circle e15c +remove_circle_outline e15d +remove_done e9d3 +remove_from_queue e067 +remove_moderator e9d4 +remove_red_eye e417 +remove_road ebfc +remove_shopping_cart e928 +reorder e8fe +repartition f8e8 +repeat e040 +repeat_on e9d6 +repeat_one e041 +repeat_one_on e9d7 +replay e042 +replay_10 e059 +replay_30 e05a +replay_5 e05b +replay_circle_filled e9d8 +reply e15e +reply_all e15f +report e160 +report_gmailerrorred f052 +report_off e170 +report_problem e8b2 +request_page f22c +request_quote f1b6 +reset_tv e9d9 +restart_alt f053 +restaurant e56c +restaurant_menu e561 +restore e8b3 +restore_from_trash e938 +restore_page e929 +reviews f054 +rice_bowl f1f5 +ring_volume e0d1 +rocket eba5 +rocket_launch eb9b +roller_shades ec12 +roller_shades_closed ec11 +roller_skating ebcd +roofing f201 +room e8b4 +room_preferences f1b8 +room_service eb49 +rotate_90_degrees_ccw e418 +rotate_90_degrees_cw eaab +rotate_left e419 +rotate_right e41a +roundabout_left eb99 +roundabout_right eba3 +rounded_corner e920 +route eacd +router e328 +rowing e921 +rss_feed e0e5 +rsvp f055 +rtt e9ad +rule f1c2 +rule_folder f1c9 +run_circle ef6f +running_with_errors e51d +rv_hookup e642 +safety_check ebef +safety_divider e1cc +sailing e502 +sanitizer f21d +satellite e562 +satellite_alt eb3a +save e161 +save_alt e171 +save_as eb60 +saved_search ea11 +savings e2eb +scale eb5f +scanner e329 +scatter_plot e268 +schedule e8b5 +schedule_send ea0a +schema e4fd +school e80c +science ea4b +score e269 +scoreboard ebd0 +screen_lock_landscape e1be +screen_lock_portrait e1bf +screen_lock_rotation e1c0 +screen_rotation e1c1 +screen_rotation_alt ebee +screen_search_desktop ef70 +screen_share e0e2 +screenshot f056 +screenshot_monitor ec08 +scuba_diving ebce +sd e9dd +sd_card e623 +sd_card_alert f057 +sd_storage e1c2 +search e8b6 +search_off ea76 +security e32a +security_update f058 +security_update_good f059 +security_update_warning f05a +segment e94b +select_all e162 +self_improvement ea78 +sell f05b +send e163 +send_and_archive ea0c +send_time_extension eadb +send_to_mobile f05c +sensor_door f1b5 +sensor_occupied ec10 +sensor_window f1b4 +sensors e51e +sensors_off e51f +sentiment_dissatisfied e811 +sentiment_neutral e812 +sentiment_satisfied e813 +sentiment_satisfied_alt e0ed +sentiment_very_dissatisfied e814 +sentiment_very_satisfied e815 +set_meal f1ea +settings e8b8 +settings_accessibility f05d +settings_applications e8b9 +settings_backup_restore e8ba +settings_bluetooth e8bb +settings_brightness e8bd +settings_cell e8bc +settings_display e8bd +settings_ethernet e8be +settings_input_antenna e8bf +settings_input_component e8c0 +settings_input_composite e8c1 +settings_input_hdmi e8c2 +settings_input_svideo e8c3 +settings_overscan e8c4 +settings_phone e8c5 +settings_power e8c6 +settings_remote e8c7 +settings_suggest f05e +settings_system_daydream e1c3 +settings_voice e8c8 +severe_cold ebd3 +shape_line f8d3 +share e80d +share_arrival_time e524 +share_location f05f +shield e9e0 +shield_moon eaa9 +shop e8c9 +shop_2 e19e +shop_two e8ca +shopify ea9d +shopping_bag f1cc +shopping_basket e8cb +shopping_cart e8cc +shopping_cart_checkout eb88 +short_text e261 +shortcut f060 +show_chart e6e1 +shower f061 +shuffle e043 +shuffle_on e9e1 +shutter_speed e43d +sick f220 +sign_language ebe5 +signal_cellular_0_bar f0a8 +signal_cellular_4_bar e1c8 +signal_cellular_alt e202 +signal_cellular_alt_1_bar ebdf +signal_cellular_alt_2_bar ebe3 +signal_cellular_connected_no_internet_0_bar f0ac +signal_cellular_connected_no_internet_4_bar e1cd +signal_cellular_no_sim e1ce +signal_cellular_nodata f062 +signal_cellular_null e1cf +signal_cellular_off e1d0 +signal_wifi_0_bar f0b0 +signal_wifi_4_bar e1d8 +signal_wifi_4_bar_lock e1d9 +signal_wifi_bad f063 +signal_wifi_connected_no_internet_4 f064 +signal_wifi_off e1da +signal_wifi_statusbar_4_bar f065 +signal_wifi_statusbar_connected_no_internet_4 f066 +signal_wifi_statusbar_null f067 +signpost eb91 +sim_card e32b +sim_card_alert e624 +sim_card_download f068 +single_bed ea48 +sip f069 +skateboarding e511 +skip_next e044 +skip_previous e045 +sledding e512 +slideshow e41b +slow_motion_video e068 +smart_button f1c1 +smart_display f06a +smart_screen f06b +smart_toy f06c +smartphone e32c +smoke_free eb4a +smoking_rooms eb4b +sms e625 +sms_failed e626 +snapchat ea6e +snippet_folder f1c7 +snooze e046 +snowboarding e513 +snowmobile e503 +snowshoeing e514 +soap f1b2 +social_distance e1cb +solar_power ec0f +sort e164 +sort_by_alpha e053 +sos ebf7 +soup_kitchen e7d3 +source f1c4 +south f1e3 +south_america e7e4 +south_east f1e4 +south_west f1e5 +spa eb4c +space_bar e256 +space_dashboard e66b +spatial_audio ebeb +spatial_audio_off ebe8 +spatial_tracking ebea +speaker e32d +speaker_group e32e +speaker_notes e8cd +speaker_notes_off e92a +speaker_phone e0d2 +speed e9e4 +spellcheck e8ce +splitscreen f06d +spoke e9a7 +sports ea30 +sports_bar f1f3 +sports_baseball ea51 +sports_basketball ea26 +sports_cricket ea27 +sports_esports ea28 +sports_football ea29 +sports_golf ea2a +sports_gymnastics ebc4 +sports_handball ea33 +sports_hockey ea2b +sports_kabaddi ea34 +sports_martial_arts eae9 +sports_mma ea2c +sports_motorsports ea2d +sports_rugby ea2e +sports_score f06e +sports_soccer ea2f +sports_tennis ea32 +sports_volleyball ea31 +square eb36 +square_foot ea49 +ssid_chart eb66 +stacked_bar_chart e9e6 +stacked_line_chart f22b +stadium eb90 +stairs f1a9 +star e838 +star_border e83a +star_border_purple500 f099 +star_half e839 +star_outline f06f +star_purple500 f09a +star_rate f0ec +stars e8d0 +start e089 +stay_current_landscape e0d3 +stay_current_portrait e0d4 +stay_primary_landscape e0d5 +stay_primary_portrait e0d6 +sticky_note_2 f1fc +stop e047 +stop_circle ef71 +stop_screen_share e0e3 +storage e1db +store e8d1 +store_mall_directory e563 +storefront ea12 +storm f070 +straight eb95 +straighten e41c +stream e9e9 +streetview e56e +strikethrough_s e257 +stroller f1ae +style e41d +subdirectory_arrow_left e5d9 +subdirectory_arrow_right e5da +subject e8d2 +subscript f111 +subscriptions e064 +subtitles e048 +subtitles_off ef72 +subway e56f +summarize f071 +superscript f112 +supervised_user_circle e939 +supervisor_account e8d3 +support ef73 +support_agent f0e2 +surfing e515 +surround_sound e049 +swap_calls e0d7 +swap_horiz e8d4 +swap_horizontal_circle e933 +swap_vert e8d5 +swap_vert_circle e8d6 +swap_vertical_circle e8d6 +swipe e9ec +swipe_down eb53 +swipe_down_alt eb30 +swipe_left eb59 +swipe_left_alt eb33 +swipe_right eb52 +swipe_right_alt eb56 +swipe_up eb2e +swipe_up_alt eb35 +swipe_vertical eb51 +switch_access_shortcut e7e1 +switch_access_shortcut_add e7e2 +switch_account e9ed +switch_camera e41e +switch_left f1d1 +switch_right f1d2 +switch_video e41f +synagogue eab0 +sync e627 +sync_alt ea18 +sync_disabled e628 +sync_lock eaee +sync_problem e629 +system_security_update f072 +system_security_update_good f073 +system_security_update_warning f074 +system_update e62a +system_update_alt e8d7 +system_update_tv e8d7 +tab e8d8 +tab_unselected e8d9 +table_bar ead2 +table_chart e265 +table_restaurant eac6 +table_rows f101 +table_view f1be +tablet e32f +tablet_android e330 +tablet_mac e331 +tag e9ef +tag_faces e420 +takeout_dining ea74 +tap_and_play e62b +tapas f1e9 +task f075 +task_alt e2e6 +taxi_alert ef74 +telegram ea6b +temple_buddhist eab3 +temple_hindu eaaf +terminal eb8e +terrain e564 +text_decrease eadd +text_fields e262 +text_format e165 +text_increase eae2 +text_rotate_up e93a +text_rotate_vertical e93b +text_rotation_angledown e93c +text_rotation_angleup e93d +text_rotation_down e93e +text_rotation_none e93f +text_snippet f1c6 +textsms e0d8 +texture e421 +theater_comedy ea66 +theaters e8da +thermostat f076 +thermostat_auto f077 +thumb_down e8db +thumb_down_alt e816 +thumb_down_off_alt e9f2 +thumb_up e8dc +thumb_up_alt e817 +thumb_up_off_alt e9f3 +thumbs_up_down e8dd +thunderstorm ebdb +tiktok ea7e +time_to_leave e62c +timelapse e422 +timeline e922 +timer e425 +timer_10 e423 +timer_10_select f07a +timer_3 e424 +timer_3_select f07b +timer_off e426 +tips_and_updates e79a +tire_repair ebc8 +title e264 +toc e8de +today e8df +toggle_off e9f5 +toggle_on e9f6 +token ea25 +toll e8e0 +tonality e427 +topic f1c8 +tornado e199 +touch_app e913 +tour ef75 +toys e332 +track_changes e8e1 +traffic e565 +train e570 +tram e571 +transcribe f8ec +transfer_within_a_station e572 +transform e428 +transgender e58d +transit_enterexit e579 +translate e8e2 +travel_explore e2db +trending_down e8e3 +trending_flat e8e4 +trending_neutral e8e4 +trending_up e8e5 +trip_origin e57b +troubleshoot e1d2 +try f07c +tsunami ebd8 +tty f1aa +tune e429 +tungsten f07d +turn_left eba6 +turn_right ebab +turn_sharp_left eba7 +turn_sharp_right ebaa +turn_slight_left eba4 +turn_slight_right eb9a +turned_in e8e6 +turned_in_not e8e7 +tv e333 +tv_off e647 +two_wheeler e9f9 +type_specimen f8f0 +u_turn_left eba1 +u_turn_right eba2 +umbrella f1ad +unarchive e169 +undo e166 +unfold_less e5d6 +unfold_less_double f8cf +unfold_more e5d7 +unfold_more_double f8d0 +unpublished f236 +unsubscribe e0eb +upcoming f07e +update e923 +update_disabled e075 +upgrade f0fb +upload f09b +upload_file e9fc +usb e1e0 +usb_off e4fa +vaccines e138 +vape_free ebc6 +vaping_rooms ebcf +verified ef76 +verified_user e8e8 +vertical_align_bottom e258 +vertical_align_center e259 +vertical_align_top e25a +vertical_distribute e076 +vertical_shades ec0e +vertical_shades_closed ec0d +vertical_split e949 +vibration e62d +video_call e070 +video_camera_back f07f +video_camera_front f080 +video_chat f8a0 +video_collection e04a +video_file eb87 +video_label e071 +video_library e04a +video_settings ea75 +video_stable f081 +videocam e04b +videocam_off e04c +videogame_asset e338 +videogame_asset_off e500 +view_agenda e8e9 +view_array e8ea +view_carousel e8eb +view_column e8ec +view_comfortable e42a +view_comfy e42a +view_comfy_alt eb73 +view_compact e42b +view_compact_alt eb74 +view_cozy eb75 +view_day e8ed +view_headline e8ee +view_in_ar e9fe +view_kanban eb7f +view_list e8ef +view_module e8f0 +view_quilt e8f1 +view_sidebar f114 +view_stream e8f2 +view_timeline eb85 +view_week e8f3 +vignette e435 +villa e586 +visibility e8f4 +visibility_off e8f5 +voice_chat e62e +voice_over_off e94a +voicemail e0d9 +volcano ebda +volume_down e04d +volume_mute e04e +volume_off e04f +volume_up e050 +volunteer_activism ea70 +vpn_key e0da +vpn_key_off eb7a +vpn_lock e62f +vrpano f082 +wallet f8ff +wallet_giftcard e8f6 +wallet_membership e8f7 +wallet_travel e8f8 +wallpaper e75f +warehouse ebb8 +warning e002 +warning_amber f083 +wash f1b1 +watch e334 +watch_later e924 +watch_off eae3 +water f084 +water_damage f203 +water_drop e798 +waterfall_chart ea00 +waves e176 +waving_hand e766 +wb_auto e42c +wb_cloudy e42d +wb_incandescent e42e +wb_iridescent e436 +wb_shade ea01 +wb_sunny e430 +wb_twilight e1c6 +wc e63d +web e051 +web_asset e069 +web_asset_off e4f7 +web_stories e595 +webhook eb92 +wechat ea81 +weekend e16b +west f1e6 +whatshot e80e +wheelchair_pickup f1ab +where_to_vote e177 +widgets e75e +width_full f8f5 +width_normal f8f6 +width_wide f8f7 +wifi e63e +wifi_1_bar e4ca +wifi_2_bar e4d9 +wifi_calling ef77 +wifi_calling_3 f085 +wifi_channel eb6a +wifi_find eb31 +wifi_lock e1e1 +wifi_off e648 +wifi_password eb6b +wifi_protected_setup f0fc +wifi_tethering e1e2 +wifi_tethering_error f086 +wifi_tethering_error_rounded f086 +wifi_tethering_off f087 +wind_power ec0c +window f088 +wine_bar f1e8 +woman e13e +woman_2 f8e7 +woo_commerce ea6d +wordpress ea9f +work e8f9 +work_history ec09 +work_off e942 +work_outline e943 +workspace_premium e7af +workspaces e1a0 +wrap_text e25b +wrong_location ef78 +wysiwyg f1c3 +yard f089 +youtube_searched_for e8fa +zoom_in e8ff +zoom_in_map eb2d +zoom_out e900 +zoom_out_map e56b diff --git a/src/lib/icons/material-icons.test.ts b/src/lib/icons/material-icons.test.ts new file mode 100644 index 0000000..da483fb --- /dev/null +++ b/src/lib/icons/material-icons.test.ts @@ -0,0 +1,186 @@ +import { readdirSync, readFileSync } from 'node:fs'; +import { join, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +/** + * Guardia contra ligaduras fantasma en la fuente de iconos. + * + * Cuando el nombre de un icono no existe en la fuente cargada, el navegador NO falla: + * renderiza el nombre como texto literal. Un `progress_activity` (de Material Symbols, 2023) + * se pinta como la cadena "progress_activity" de ~200 px a los 24 px que fija la clase + * `.material-icons-outlined`, y con `animate-spin` la hace girar. Eso desbordó el pie del modal + * de instalación de /versiones-cras y recortó el botón "Cerrar" fuera del panel. + * + * Ni `tsc --noEmit` ni el build lo detectan: no es error de tipos ni de sintaxis. Este test es + * la única compuerta. + */ + +const SRC_DIR = fileURLToPath(new URL('../../', import.meta.url)); +const CODEPOINTS_FILE = fileURLToPath( + new URL('./material-icons-outlined.codepoints', import.meta.url) +); + +const ICON_CLASS = 'material-icons-outlined'; + +/** Cuerpo del span que es exactamente un nombre de icono. */ +const LIGATURE_BODY = /^[a-z0-9][a-z0-9_]*$/; +/** + * Literales en posición de comparación (`tone === 'warn'`) no son nombres de icono: se + * descartan antes de extraer, o cada `class:text-red-600={c.status === 'fail'}` metería basura. + */ +const COMPARISON = /[=!]==?\s*'[a-z0-9_]*'/g; +/** Literales de una expresión inline: `{cond ? 'check_circle' : 'error_outline'}`. */ +const EXPRESSION_LITERAL = /'([a-z][a-z0-9_]{2,})'/g; +/** Nombres declarados como dato: `{ key: 'x', icon: 'inventory_2' }`. */ +const ICON_PROPERTY = /\bicon:\s*'([a-z0-9][a-z0-9_]*)'/g; +/** Mapas de nombres de icono: `const CHECK_ICON: Record = { ... };`. */ +const ICON_MAP = /const\s+[A-Z0-9_]*ICON[A-Z0-9_]*[^=]*=\s*\{([\s\S]*?)\n\s*\};/g; + +/** + * Lista blanca autoritativa: el .codepoints del release de google/material-design-icons, el + * mismo con el que se genera el .woff2 que sirve Google. Una lista curada a mano no sirve: + * cualquiera le agregaría el nombre nuevo sin verificar que exista, que es justo el error que + * este test previene. + */ +const VALID_LIGATURES = new Set( + readFileSync(CODEPOINTS_FILE, 'utf8') + .split('\n') + .map((line) => line.trim().split(' ')[0]) + .filter(Boolean) +); + +interface Usage { + name: string; + file: string; + line: number; +} + +function walk(dir: string, extensions: string[]): string[] { + const found: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) found.push(...walk(full, extensions)); + else if (extensions.some((ext) => entry.name.endsWith(ext))) found.push(full); + } + return found; +} + +function lineOf(text: string, index: number): number { + let line = 1; + for (let i = 0; i < index; i++) if (text[i] === '\n') line += 1; + return line; +} + +/** Primer '>' que cierra la etiqueta, ignorando las flechas '=>' de los handlers. */ +function findTagEnd(text: string, from: number): number { + for (let i = from; i < text.length; i++) { + if (text[i] === '>' && text[i - 1] !== '=') return i; + } + return -1; +} + +function literalsIn(expression: string): string[] { + return [...expression.replace(COMPARISON, '').matchAll(EXPRESSION_LITERAL)].map((m) => m[1]); +} + +/** Ligaduras escritas dentro de un elemento que lleva la clase de iconos. */ +function fromIconElements(text: string, file: string): Usage[] { + const found: Usage[] = []; + let cursor = 0; + for (;;) { + const at = text.indexOf(ICON_CLASS, cursor); + if (at === -1) break; + cursor = at + ICON_CLASS.length; + + const tagEnd = findTagEnd(text, cursor); + if (tagEnd === -1) break; + const nextTag = text.indexOf('<', tagEnd + 1); + const body = text.slice(tagEnd + 1, nextTag === -1 ? text.length : nextTag).trim(); + const line = lineOf(text, at); + + if (LIGATURE_BODY.test(body)) { + found.push({ name: body, file, line }); + } else { + // Expresión inline. Las referencias (`{item.icon}`) las cubre fromIconData. + for (const name of literalsIn(body)) found.push({ name, file, line }); + } + if (nextTag !== -1) cursor = nextTag; + } + return found; +} + +/** + * Nombres que viven como dato en lugar de en la plantilla: propiedades `icon:` (nav.ts, + * AppShell, STATUS_META) y mapas `const *ICON* = { ... }` (CHECK_ICON). Convención: todo mapa de + * nombres de icono debe llamarse *ICON* o exponerlos en una propiedad `icon`, o este test no lo ve. + */ +function fromIconData(text: string, file: string): Usage[] { + const found: Usage[] = []; + for (const match of text.matchAll(ICON_PROPERTY)) { + found.push({ name: match[1], file, line: lineOf(text, match.index ?? 0) }); + } + for (const declaration of text.matchAll(ICON_MAP)) { + const line = lineOf(text, declaration.index ?? 0); + for (const name of literalsIn(declaration[1])) found.push({ name, file, line }); + } + return found; +} + +function usagesIn(file: string): Usage[] { + const text = readFileSync(file, 'utf8'); + return file.endsWith('.svelte') + ? [...fromIconElements(text, file), ...fromIconData(text, file)] + : fromIconData(text, file); +} + +describe('ligaduras de Material Icons Outlined', () => { + const files = walk(SRC_DIR, ['.svelte', '.ts']).filter((file) => !file.endsWith('.test.ts')); + + it('carga el set clásico completo como lista blanca', () => { + expect(VALID_LIGATURES.size).toBeGreaterThan(2000); + expect(VALID_LIGATURES.has('sync')).toBe(true); + expect(VALID_LIGATURES.has('autorenew')).toBe(true); + // De Material Symbols (2023), no del set clásico. Si esta aserción empieza a fallar es + // que alguien cambió el .codepoints por el de Symbols sin migrar app.html. + expect(VALID_LIGATURES.has('progress_activity')).toBe(false); + }); + + it('detecta ligaduras en las cuatro formas de uso del panel', () => { + const names = new Set(files.flatMap((file) => usagesIn(file)).map((usage) => usage.name)); + // Si el extractor se rompe, dejaría de proteger sin dar señal: estas cuatro formas + // cubren todo lo que hay hoy en src/. + expect(names.has('network_check')).toBe(true); // literal en el cuerpo del span + expect(names.has('chevron_left')).toBe(true); // ternario inline (AppShell) + expect(names.has('remove_circle_outline')).toBe(true); // mapa CHECK_ICON + expect(names.has('cleaning_services')).toBe(true); // propiedad icon: (AppShell) + expect(names.size).toBeGreaterThan(50); + }); + + it('todas las ligaduras usadas existen en la fuente que carga el panel', () => { + const invalid = files + .flatMap((file) => usagesIn(file)) + .filter((usage) => !VALID_LIGATURES.has(usage.name)) + .map((usage) => `${relative(SRC_DIR, usage.file)}:${usage.line} → "${usage.name}"`); + + expect( + invalid, + 'Estas ligaduras no existen en Material Icons Outlined. El navegador renderiza el ' + + 'nombre como TEXTO LITERAL en lugar del icono y desborda el contenedor. Usa un ' + + 'nombre del set clásico (ver material-icons-outlined.codepoints) o un SVG inline ' + + '(ver $lib/components/Spinner.svelte):\n' + + invalid.join('\n') + ).toEqual([]); + }); + + it('la fuente declarada es la misma cuya clase usan las plantillas', () => { + const appHtml = readFileSync(join(SRC_DIR, 'app.html'), 'utf8'); + const appCss = readFileSync(join(SRC_DIR, 'app.css'), 'utf8'); + // Servida por CDN (hoy) o self-hosted con @font-face en app.css (paso siguiente). + const declared = + appHtml.includes('Material+Icons+Outlined') || appCss.includes(`.${ICON_CLASS}`); + expect(declared, 'Nadie declara la fuente de iconos que usan las plantillas').toBe(true); + // Una migración a Symbols a medias dejaría las 110 ligaduras sin su clase. + expect(appHtml).not.toContain('Material+Symbols'); + }); +}); diff --git a/src/lib/nav.test.ts b/src/lib/nav.test.ts index 84911c4..0186e2b 100644 --- a/src/lib/nav.test.ts +++ b/src/lib/nav.test.ts @@ -30,7 +30,7 @@ describe('panelViewsFor', () => { const views = panelViewsFor(false); expect(views.every((view) => !view.adminOnly)).toBe(true); const excluded = PANEL_VIEWS.filter((view) => view.adminOnly).map((view) => view.key); - expect(excluded).toEqual(['clients', 'databases']); + expect(excluded).toEqual(['restored', 'failed', 'clients', 'databases']); expect(views.map((view) => view.key)).toEqual( PANEL_VIEWS.filter((view) => !view.adminOnly).map((view) => view.key) ); diff --git a/src/lib/nav.ts b/src/lib/nav.ts index fcb4d6f..1e2baef 100644 --- a/src/lib/nav.ts +++ b/src/lib/nav.ts @@ -12,8 +12,8 @@ export type PanelView = { const PANEL_VIEW_DEFS = [ { key: 'dashboard', label: 'Resumen', icon: 'insights' }, { key: 'backups', label: 'Respaldos Almacenados', icon: 'inventory_2' }, - { key: 'restored', label: 'Respaldos Restaurados', icon: 'cloud_done' }, - { key: 'failed', label: 'Restores Fallidos', icon: 'error_outline' }, + { key: 'restored', label: 'Respaldos Restaurados', icon: 'cloud_done', adminOnly: true }, + { key: 'failed', label: 'Restores Fallidos', icon: 'error_outline', adminOnly: true }, { key: 'clients', label: 'Catálogo de Clientes', icon: 'group', adminOnly: true }, { key: 'alerts', label: 'Alertas Críticas', icon: 'warning_amber' }, { key: 'databases', label: 'Gestión de Bases de Datos', icon: 'dns', adminOnly: true } diff --git a/src/lib/server/controldesk-pg.test.ts b/src/lib/server/controldesk-pg.test.ts index 1ab1ba8..9e0365a 100644 --- a/src/lib/server/controldesk-pg.test.ts +++ b/src/lib/server/controldesk-pg.test.ts @@ -1,8 +1,23 @@ /** - * Pruebas de matchNodeRowFromBackupStem (resolución de nodo desde nombre de archivo). + * Pruebas de matchNodeRowFromBackupStem (resolución de nodo desde nombre de archivo) y de + * deleteRestoreJobLogs (borrado definitivo de restores fallidos). */ -import { describe, it, expect } from 'vitest'; -import { matchNodeRowFromBackupStem } from './controldesk-pg'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// vi.mock se iza al tope del archivo, así que las factories no pueden capturar variables +// declaradas después: se usa vi.hoisted, igual que en api/alerts/send/server.test.ts. +const { clientQuery, clientRelease, connectMock } = vi.hoisted(() => { + const clientQuery = vi.fn(); + const clientRelease = vi.fn(); + return { + clientQuery, + clientRelease, + connectMock: vi.fn(async () => ({ query: clientQuery, release: clientRelease })) + }; +}); +vi.mock('$lib/server/db', () => ({ pgPool: { query: vi.fn(), connect: connectMock } })); + +import { matchNodeRowFromBackupStem, deleteRestoreJobLogs } from './controldesk-pg'; const NODES = [ { @@ -30,3 +45,98 @@ describe('matchNodeRowFromBackupStem', () => { expect(matchNodeRowFromBackupStem('DESCONOCIDO', NODES)).toBeNull(); }); }); + +/** + * deleteRestoreJobLogs BORRA definitivamente. Lo que se prueba aquí es lo que hace peligroso + * un DELETE: que vaya en transacción, que cuente antes, que no toque restores exitosos, y que + * el número que reporta sea el de filas realmente borradas y no el de IDs recibidos. + */ +describe('deleteRestoreJobLogs', () => { + /** Simula el par COUNT previo + DELETE dentro de la transacción. */ + function stubTx(eligible: number, notFailed: number, deleted: number) { + clientQuery.mockReset(); + clientQuery.mockImplementation(async (sql: string) => { + if (/^\s*(BEGIN|COMMIT|ROLLBACK)/i.test(sql)) return { rows: [], rowCount: 0 }; + if (/^\s*SELECT/i.test(sql)) { + return { rows: [{ eligible: String(eligible), not_failed: String(notFailed) }] }; + } + if (/^\s*DELETE/i.test(sql)) return { rows: [], rowCount: deleted }; + throw new Error(`SQL inesperado: ${sql}`); + }); + } + + beforeEach(() => { + clientQuery.mockReset(); + clientRelease.mockReset(); + connectMock.mockClear(); + }); + + it('borra en transacción, contando antes del DELETE', async () => { + stubTx(2, 0, 2); + const r = await deleteRestoreJobLogs([10, 11]); + + expect(r).toEqual({ requested: 2, eligible: 2, deleted: 2, skippedNotFailed: 0 }); + + const sqls = clientQuery.mock.calls.map((c) => String(c[0]).trim()); + expect(sqls[0]).toMatch(/^BEGIN/); + expect(sqls.at(-1)).toMatch(/^COMMIT/); + // El COUNT tiene que ir ANTES del DELETE, o no sirve para reportar nada. + const iCount = sqls.findIndex((s) => /^SELECT/i.test(s)); + const iDelete = sqls.findIndex((s) => /^DELETE/i.test(s)); + expect(iCount).toBeGreaterThan(0); + expect(iDelete).toBeGreaterThan(iCount); + expect(clientRelease).toHaveBeenCalledOnce(); + }); + + it("solo borra filas con status 'failed'", async () => { + stubTx(1, 0, 1); + await deleteRestoreJobLogs([7]); + const del = clientQuery.mock.calls.map((c) => String(c[0])).find((s) => /DELETE/i.test(s)); + // La restricción vive en el servidor, no solo en la UI: los restores exitosos son el + // registro de auditoría del que sale "última restauración exitosa". + expect(del).toMatch(/status\s*=\s*'failed'/); + }); + + it('reporta los que existen pero no son fallidos, sin borrarlos', async () => { + stubTx(0, 3, 0); + const r = await deleteRestoreJobLogs([1, 2, 3]); + expect(r.deleted).toBe(0); + expect(r.skippedNotFailed).toBe(3); + }); + + it('devuelve las filas REALMENTE borradas, no la cantidad de IDs pedidos', async () => { + // Caso real: la pestaña llevaba rato abierta y otro operador ya borró dos de los tres. + stubTx(1, 0, 1); + const r = await deleteRestoreJobLogs([4, 5, 6]); + expect(r.requested).toBe(3); + expect(r.deleted).toBe(1); + }); + + it('sanea los IDs: descarta 0, negativos y no enteros, y deduplica', async () => { + stubTx(1, 0, 1); + await deleteRestoreJobLogs([5, 5, 0, -3, 2.7, Number.NaN, Infinity] as number[]); + const call = clientQuery.mock.calls.find((c) => /SELECT/i.test(String(c[0]))); + expect(call?.[1]).toEqual([[5]]); + }); + + it('no abre transacción si no queda ningún ID válido', async () => { + const r = await deleteRestoreJobLogs([0, -1, Number.NaN]); + expect(r).toEqual({ requested: 0, eligible: 0, deleted: 0, skippedNotFailed: 0 }); + expect(connectMock).not.toHaveBeenCalled(); + }); + + it('hace ROLLBACK y libera el cliente si el DELETE falla', async () => { + clientQuery.mockReset(); + clientQuery.mockImplementation(async (sql: string) => { + if (/^\s*DELETE/i.test(sql)) throw new Error('deadlock detectado'); + if (/^\s*SELECT/i.test(sql)) return { rows: [{ eligible: '1', not_failed: '0' }] }; + return { rows: [], rowCount: 0 }; + }); + + await expect(deleteRestoreJobLogs([9])).rejects.toThrow('deadlock detectado'); + const sqls = clientQuery.mock.calls.map((c) => String(c[0]).trim()); + expect(sqls.some((s) => /^ROLLBACK/i.test(s))).toBe(true); + expect(sqls.some((s) => /^COMMIT/i.test(s))).toBe(false); + expect(clientRelease).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/lib/server/controldesk-pg.ts b/src/lib/server/controldesk-pg.ts index 1826210..4c7fd6e 100644 --- a/src/lib/server/controldesk-pg.ts +++ b/src/lib/server/controldesk-pg.ts @@ -89,15 +89,7 @@ async function ensureCloudRestoreStatusTable(): Promise { 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)`, - // Descartar en lugar de borrar: las vistas de restaurados/fallidos filtran por - // dismissed_at IS NULL, pero listRestoreJobLogSummaries sigue contando TODO. Un DELETE - // real bajaría los contadores de /servidores-restauracion y podría retroceder la última - // restauración exitosa de un servidor, moviendo números de otra pantalla. - `ALTER TABLE ${qRestoreJobLogs()} ADD COLUMN IF NOT EXISTS dismissed_at TIMESTAMPTZ`, - `ALTER TABLE ${qRestoreJobLogs()} ADD COLUMN IF NOT EXISTS dismissed_by VARCHAR(128)`, - `CREATE INDEX IF NOT EXISTS idx_a24c_restore_job_logs_pendientes - ON ${qRestoreJobLogs()} (status, restored_at DESC) WHERE dismissed_at IS NULL` + `ALTER TABLE ${qRestoreJobLogs()} ADD COLUMN IF NOT EXISTS rel_path VARCHAR(600)` ]) { try { await pgPool.query(stmt); @@ -194,7 +186,9 @@ const ROW_DATABASE_NODE = ` notification_email AS "CorreoNotificacion", server_name AS "ServerName", database_name AS "BDName", - restore_target_id AS "RestoreTargetId" + restore_target_id AS "RestoreTargetId", + anexo24c_aviso_fecha AS "Anexo24CAvisoFecha", + anexo24c_cambio_sistema AS "Anexo24CCambioSistema" `; const ROW_PORTAL_USER = ` @@ -363,18 +357,17 @@ export function matchNodeRowFromBackupStem(stem: string, nodes: any[]): any | nu /** Datos de contacto para alertas (equivalente a la consulta previa sobre Usuarios/BasesDeDatos). */ export async function lookupAlertClientData(nodoName: string): Promise { + // El "Cliente" de la alerta es el nombre de la tabla de bases de datos + // (database_nodes.legal_name), NO el full_name del usuario del portal. Se resuelve el nodo por + // database_name o node_subnode_key; así también funciona para bases sin usuario asociado. const sql = ` SELECT - pu.is_authority_client AS "ClienteAutoridad", - pu.full_name AS "Nombre", - pu.username AS "Usuario", + dn.legal_name AS "Nombre", dn.notification_email AS "CorreoNotificacion", dn.node_subnode_key AS "NodoSubNodo" - FROM ${qUsers()} pu - LEFT JOIN ${qNodes()} dn ON pu.database_node_id = dn.id - WHERE pu.username = $1 - OR dn.database_name = $1 - OR dn.node_subnode_key = $1 + FROM ${qNodes()} dn + WHERE LOWER(TRIM(dn.database_name)) = LOWER(TRIM($1::text)) + OR LOWER(TRIM(dn.node_subnode_key)) = LOWER(TRIM($1::text)) LIMIT 1 `; const r = await pgPool.query(sql, [nodoName]); @@ -399,6 +392,8 @@ export async function insertDatabaseNode(row: { bdName: string; activo: number; restoreTargetId?: number | null; + anexo24CAvisoFecha?: string | null; + anexo24CCambioSistema?: boolean; }): Promise { // server_name y sql_password se derivan del servidor de restauración asignado (su IP y su // credencial SQL cifrada); si el target aún no tiene IP, se conserva el serverName recibido. @@ -407,12 +402,13 @@ export async function insertDatabaseNode(row: { INSERT INTO ${qNodes()} ( node_subnode_key, rfc, legal_name, branch_name, notification_email, server_name, database_name, is_active, restore_target_id, - sql_password + sql_password, anexo24c_aviso_fecha, anexo24c_cambio_sistema ) VALUES ( $1, $2, $3, $4, $5, COALESCE((SELECT server_ip FROM ${qRestoreTargets()} WHERE id = $9), $6), $7, $8, $9, - (SELECT sql_password_encrypted FROM ${qRestoreTargets()} WHERE id = $9) + (SELECT sql_password_encrypted FROM ${qRestoreTargets()} WHERE id = $9), + $10, $11 ) `, [ @@ -424,7 +420,9 @@ export async function insertDatabaseNode(row: { row.serverName, row.bdName, row.activo, - row.restoreTargetId ?? null + row.restoreTargetId ?? null, + row.anexo24CAvisoFecha ?? null, + row.anexo24CCambioSistema ?? false ] ); } @@ -441,6 +439,8 @@ export async function updateDatabaseNode( bdName: string; activo: number; restoreTargetId?: number | null; + anexo24CAvisoFecha?: string | null; + anexo24CCambioSistema?: boolean; } ): Promise { await pgPool.query( @@ -455,8 +455,10 @@ export async function updateDatabaseNode( database_name = $7, is_active = $8, restore_target_id = $9, - sql_password = (SELECT sql_password_encrypted FROM ${qRestoreTargets()} WHERE id = $9) - WHERE id = $10 + sql_password = (SELECT sql_password_encrypted FROM ${qRestoreTargets()} WHERE id = $9), + anexo24c_aviso_fecha = $10, + anexo24c_cambio_sistema = $11 + WHERE id = $12 `, [ row.nodoSubNodo, @@ -468,6 +470,8 @@ export async function updateDatabaseNode( row.bdName, row.activo, row.restoreTargetId ?? null, + row.anexo24CAvisoFecha ?? null, + row.anexo24CCambioSistema ?? false, id ] ); @@ -676,6 +680,46 @@ export async function getRestoreTargetSsh(id: number): Promise { + const r = await pgPool.query( + `SELECT ${ROW_RESTORE_TARGET}, sql_password_encrypted FROM ${qRestoreTargets()} WHERE id = $1`, + [id] + ); + const row = r.rows[0]; + if (!row) return null; + const { sql_password_encrypted, ...rest } = row; + const sql_password = sql_password_encrypted ? decryptSecret(sql_password_encrypted) : ''; + return { ...(rest as RestoreTarget), sql_password }; +} + +/** + * Servidor de restauración por id con AMBAS contraseñas descifradas (SQL para BACKUP/RESTORE, SSH + * para SFTP). Uso exclusivo del servidor (mover bases duplicadas); NUNCA se expone al cliente. + */ +export async function getRestoreTargetWithSecretsById( + id: number +): Promise<(RestoreTarget & { sql_password: string; ssh_password: string }) | null> { + const r = await pgPool.query( + `SELECT ${ROW_RESTORE_TARGET}, sql_password_encrypted, ssh_password_encrypted + FROM ${qRestoreTargets()} WHERE id = $1`, + [id] + ); + const row = r.rows[0]; + if (!row) return null; + const { sql_password_encrypted, ssh_password_encrypted, ...rest } = row; + return { + ...(rest as RestoreTarget), + sql_password: sql_password_encrypted ? decryptSecret(sql_password_encrypted) : '', + ssh_password: ssh_password_encrypted ? decryptSecret(ssh_password_encrypted) : '' + }; +} + /** * Devuelve el servidor de restauración ASIGNADO a una base de datos, con la contraseña * descifrada. La base se identifica por database_name o node_subnode_key (lo que CloudRestoreAS @@ -1305,14 +1349,9 @@ export interface FailedRestoreRow { rel_path: string | null; size_bytes: number | null; restored_at: Date; - /** No nulo cuando el operador lo descartó de la lista (el registro se conserva). */ - dismissed_at: Date | null; } -export async function listFailedRestoreJobLogs( - limit = 100, - includeDismissed = false -): Promise { +export async function listFailedRestoreJobLogs(limit = 100): Promise { const capped = Math.min(Math.max(1, Math.trunc(limit)), 500); try { await ensureRestoreJobLogColumns(); @@ -1321,15 +1360,14 @@ export async function listFailedRestoreJobLogs( 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, jl.dismissed_at + jl.restored_at FROM ${qRestoreJobLogs()} jl LEFT JOIN ${qRestoreTargets()} rt ON rt.id = jl.restore_target_id WHERE jl.status = 'failed' - AND ($2::boolean OR jl.dismissed_at IS NULL) ORDER BY jl.restored_at DESC LIMIT $1 `, - [capped, includeDismissed] + [capped] ); return r.rows as FailedRestoreRow[]; } catch (e) { @@ -1350,7 +1388,6 @@ export interface RestoredRestoreRow { rel_path: string | null; size_bytes: number | null; restored_at: Date; - dismissed_at: Date | null; } /** @@ -1359,10 +1396,7 @@ export interface RestoredRestoreRow { * de base contra database_nodes (LATERAL … LIMIT 1 para no duplicar la fila del log si dos * nodos comparten database_name). Cuando no hay match, ambos quedan null y la UI cae a db_name. */ -export async function listRestoredRestoreJobLogs( - limit = 200, - includeDismissed = false -): Promise { +export async function listRestoredRestoreJobLogs(limit = 200): Promise { const capped = Math.min(Math.max(1, Math.trunc(limit)), 500); try { await ensureRestoreJobLogColumns(); @@ -1371,8 +1405,7 @@ export async function listRestoredRestoreJobLogs( SELECT jl.id, jl.restore_target_id, rt.name AS server_name, dn.node_subnode_key AS node_key, dn.legal_name AS client_name, - jl.filename, jl.db_name, jl.rel_path, jl.size_bytes, jl.restored_at, - jl.dismissed_at + jl.filename, jl.db_name, jl.rel_path, jl.size_bytes, jl.restored_at FROM ${qRestoreJobLogs()} jl LEFT JOIN ${qRestoreTargets()} rt ON rt.id = jl.restore_target_id LEFT JOIN LATERAL ( @@ -1382,11 +1415,10 @@ export async function listRestoredRestoreJobLogs( LIMIT 1 ) dn ON true WHERE jl.status IN ('completed', 'forwarded') - AND ($2::boolean OR jl.dismissed_at IS NULL) ORDER BY jl.restored_at DESC LIMIT $1 `, - [capped, includeDismissed] + [capped] ); return r.rows as RestoredRestoreRow[]; } catch (e) { @@ -1395,47 +1427,87 @@ export async function listRestoredRestoreJobLogs( } } -/** - * Descarta registros de la bitácora: los saca de las vistas de restaurados/fallidos **sin - * borrarlos**. `listRestoreJobLogSummaries` y `listRecentRestoreJobLogs` los siguen contando, - * así que los contadores de /servidores-restauracion y la bitácora por servidor no se mueven. - * - * Recibe IDs explícitos y no un filtro: así lo descartado es exactamente lo que el operador - * vio en pantalla, sin duplicar la lógica del buscador del cliente en el servidor. - * - * El `AND dismissed_at IS NULL` hace la operación idempotente: repetir la acción devuelve 0 en - * lugar de volver a contar filas ya descartadas. - * - * @returns cuántas filas cambiaron realmente (no cuántos IDs se pidieron). - */ -export async function dismissRestoreJobLogs( - ids: number[], - dismissedBy: string | null -): Promise { - const clean = sanitizeIds(ids); - if (clean.length === 0) return 0; - await ensureRestoreJobLogColumns(); - const r = await pgPool.query( - `UPDATE ${qRestoreJobLogs()} - SET dismissed_at = now(), dismissed_by = $2 - WHERE id = ANY($1::int[]) AND dismissed_at IS NULL`, - [clean, dismissedBy] - ); - return r.rowCount ?? 0; +/** Resultado de un borrado de bitácora: lo pedido, lo elegible y lo que realmente se fue. */ +export interface DeleteRestoreLogsResult { + /** IDs válidos recibidos (tras sanear). */ + requested: number; + /** De esos, cuántos existían Y eran `failed` — el COUNT previo al DELETE. */ + eligible: number; + /** Filas efectivamente borradas. Debe coincidir con `eligible`. */ + deleted: number; + /** Cuántos se ignoraron por no ser fallidos (existen pero son completed/forwarded). */ + skippedNotFailed: number; } -/** Revierte un descarte. Devuelve cuántas filas volvieron a la lista. */ -export async function undismissRestoreJobLogs(ids: number[]): Promise { +/** + * BORRA registros de la bitácora de forma definitiva. No hay deshacer. + * + * Solo borra filas con `status = 'failed'`, y esa restricción vive aquí y no solo en la UI a + * propósito. Dos razones: + * + * 1. "Respaldos Restaurados" es el registro de auditoría de lo que sí se restauró, y de ahí + * sale `MAX(restored_at) FILTER (WHERE status='completed')` en listRestoreJobLogSummaries, + * o sea la "última restauración exitosa" que /servidores-restauracion usa como señal de + * salud. Borrar ahí no limpiaría ruido: haría retroceder un indicador. + * 2. Acota el daño de un ID equivocado en el POST a filas que de todos modos son residuo. + * + * Consecuencia aceptada y documentada: el contador de FALLAS de los últimos 30 días en + * /servidores-restauracion sí baja, porque cuenta esta misma tabla. Es el efecto buscado. + * + * Recibe IDs explícitos y no un filtro: lo borrado es exactamente lo que el operador vio en + * pantalla, sin duplicar la lógica del buscador del cliente en el servidor. + * + * Va en transacción con COUNT previo (estándar de Aduanasoft para destructivas): así se puede + * reportar por qué `deleted` no coincide con lo pedido —IDs ya borrados por otro operador, o + * filas que no son fallidas— en lugar de devolver un número sin explicación. + */ +export async function deleteRestoreJobLogs(ids: number[]): Promise { const clean = sanitizeIds(ids); - if (clean.length === 0) return 0; - await ensureRestoreJobLogColumns(); - const r = await pgPool.query( - `UPDATE ${qRestoreJobLogs()} - SET dismissed_at = NULL, dismissed_by = NULL - WHERE id = ANY($1::int[]) AND dismissed_at IS NOT NULL`, - [clean] - ); - return r.rowCount ?? 0; + const empty: DeleteRestoreLogsResult = { + requested: 0, + eligible: 0, + deleted: 0, + skippedNotFailed: 0 + }; + if (clean.length === 0) return empty; + + const client = await pgPool.connect(); + try { + await client.query('BEGIN'); + + // COUNT previo dentro de la misma transacción: cuenta lo elegible y, aparte, lo que + // existe pero NO es fallido, para distinguir "ya no estaba" de "no se permite". + const pre = await client.query( + `SELECT + count(*) FILTER (WHERE status = 'failed') AS eligible, + count(*) FILTER (WHERE status <> 'failed') AS not_failed + FROM ${qRestoreJobLogs()} + WHERE id = ANY($1::int[])`, + [clean] + ); + const eligible = Number(pre.rows[0]?.eligible ?? 0); + const skippedNotFailed = Number(pre.rows[0]?.not_failed ?? 0); + + const del = await client.query( + `DELETE FROM ${qRestoreJobLogs()} + WHERE id = ANY($1::int[]) AND status = 'failed'`, + [clean] + ); + + await client.query('COMMIT'); + return { + requested: clean.length, + eligible, + deleted: del.rowCount ?? 0, + skippedNotFailed + }; + } catch (e) { + await client.query('ROLLBACK'); + if (isPgUndefinedTable(e)) return empty; // la tabla la crea a24c + throw e; + } finally { + client.release(); + } } /** Enteros positivos únicos. Mismo criterio que assignNodesToRestoreTarget. */ diff --git a/src/lib/server/cras-artifacts.test.ts b/src/lib/server/cras-artifacts.test.ts index b249c71..57f0b7b 100644 --- a/src/lib/server/cras-artifacts.test.ts +++ b/src/lib/server/cras-artifacts.test.ts @@ -23,7 +23,8 @@ import { isSafeFileName, isSafeVersion, pruneCache, - removeCached + removeCached, + selectPrunableVersions } from './cras-artifacts'; const originalFetch = globalThis.fetch; @@ -227,3 +228,46 @@ describe('pruneCache', () => { expect(await pruneCache([])).toEqual([]); }); }); + +describe('selectPrunableVersions', () => { + // Es la previsualización que ve el operador antes de confirmar la poda: tiene que decidir con + // el mismo criterio que pruneCache, o el modal prometería borrar algo distinto de lo que borra. + const usage = [ + { version: '1.8.0', bytes: 100 }, + { version: '1.9.0', bytes: 200 }, + { version: '1.10.0', bytes: 400 } + ]; + + it('conserva las N más nuevas en orden numérico, no lexicográfico', () => { + // Con orden de texto "1.10.0" < "1.9.0" y se borraría la más nueva. + expect(selectPrunableVersions(usage, [], 2)).toEqual({ versions: ['1.8.0'], bytes: 100 }); + expect(selectPrunableVersions(usage, [], 1)).toEqual({ + versions: ['1.9.0', '1.8.0'], + bytes: 300 + }); + }); + + it('nunca propone una versión fijada y no la cuenta contra el límite', () => { + expect(selectPrunableVersions(usage, ['1.8.0'], 1)).toEqual({ + versions: ['1.9.0'], + bytes: 200 + }); + }); + + it('suma exactamente los bytes de las versiones elegidas', () => { + const plan = selectPrunableVersions(usage, [], 0); + expect(plan.versions).toEqual(['1.10.0', '1.9.0', '1.8.0']); + expect(plan.bytes).toBe(700); + }); + + it('con menos versiones que el límite el plan es vacío: el clic no haría nada', () => { + // El control debe decir "Nada que liberar" en vez de ofrecer un borrado que es un no-op. + expect(selectPrunableVersions(usage, [], 5)).toEqual({ versions: [], bytes: 0 }); + expect(selectPrunableVersions([], [], 3)).toEqual({ versions: [], bytes: 0 }); + }); + + it('ignora carpetas que no son versiones válidas', () => { + const dirty = [...usage, { version: '../etc', bytes: 999 }, { version: 'tmp', bytes: 5 }]; + expect(selectPrunableVersions(dirty, [], 3).versions).toEqual([]); + }); +}); diff --git a/src/lib/server/cras-artifacts.ts b/src/lib/server/cras-artifacts.ts index 7556584..9ccdf98 100644 --- a/src/lib/server/cras-artifacts.ts +++ b/src/lib/server/cras-artifacts.ts @@ -264,30 +264,64 @@ export async function cacheUsage(): Promise { return usage; } +export interface PrunePlan { + versions: string[]; + bytes: number; +} + +/** + * Qué versiones de la caché se podarían: las no fijadas que quedan fuera de las `keepLimit` más + * nuevas, en orden numérico de versión (1.10.0 es más nueva que 1.9.0). + * + * Pura y exportada a propósito: la UI necesita previsualizar el borrado con EXACTAMENTE el mismo + * criterio con el que se ejecuta. Antes el botón "Liberar versiones viejas" no decía cuánto + * liberaba, y con ≤3 versiones en caché no borraba nada — un control destructivo que a veces era + * un no-op silencioso. + */ +export function selectPrunableVersions( + versions: { version: string; bytes: number }[], + pinnedVersions: string[], + keepLimit: number +): PrunePlan { + const pinned = new Set(pinnedVersions.filter(isSafeVersion)); + // Orden por versión descendente: las más nuevas se conservan. + const candidates = versions + .filter((entry) => isSafeVersion(entry.version)) + .sort((a, b) => (compareVersions(b.version, a.version) ?? 0)); + + const plan: PrunePlan = { versions: [], bytes: 0 }; + let kept = 0; + for (const candidate of candidates) { + if (pinned.has(candidate.version)) continue; + kept += 1; + if (kept <= keepLimit) continue; + plan.versions.push(candidate.version); + plan.bytes += candidate.bytes; + } + return plan; +} + +/** Plan de poda con el límite configurado. Lo consume la UI para confirmar antes de borrar. */ +export async function prunePlan(pinnedVersions: string[] = []): Promise { + const usage = await cacheUsage(); + return selectPrunableVersions(usage.versions, pinnedVersions, keepVersions()); +} + /** * Poda la caché conservando las versiones indicadas y las más recientes hasta el límite. * - * `keepVersions` recibe las versiones que NO se pueden borrar (típicamente las activas): - * dejar sin artefacto local a una versión activa obligaría a re-descargar 270 MB en plena - * instalación. Devuelve las versiones eliminadas para poder reportarlas — una poda - * silenciosa se lee como "no pasó nada" cuando en realidad se liberó disco. + * `pinnedVersions` son las versiones que NO se pueden borrar (típicamente las activas): dejar sin + * artefacto local a una versión activa obligaría a re-descargar 270 MB en plena instalación. + * Devuelve las versiones eliminadas para poder reportarlas — una poda silenciosa se lee como "no + * pasó nada" cuando en realidad se liberó disco. */ export async function pruneCache(pinnedVersions: string[] = []): Promise { const limit = keepVersions(); - const pinned = new Set(pinnedVersions.filter(isSafeVersion)); const usage = await cacheUsage(); - - // Orden por versión descendente: las más nuevas se conservan. - const candidates = usage.versions - .map((v) => v.version) - .sort((a, b) => (compareVersions(b, a) ?? 0)); + const plan = selectPrunableVersions(usage.versions, pinnedVersions, limit); const removed: string[] = []; - let kept = 0; - for (const version of candidates) { - if (pinned.has(version)) continue; - kept += 1; - if (kept <= limit) continue; + for (const version of plan.versions) { try { await fs.rm(path.join(cacheDir(), version), { recursive: true, force: true }); removed.push(version); @@ -302,7 +336,11 @@ export async function pruneCache(pinnedVersions: string[] = []): Promise SFTP baja el .bak -> se comprime a .zip + * -> SFTP sube el .zip a la Entrada del nuevo (CRA lo restaura) -> verificación acotada -> si ya + * quedó bien en el nuevo, se borra del viejo (borrado automático al confirmar). + * + * Si el restore de CRA tarda más que la ventana de verificación, la base queda 'en_transito': el + * siguiente escaneo la mostrará 🟢 y el borrado se completa con el botón de borrado existente. + */ +import os from 'node:os'; +import path from 'node:path'; +import fs from 'node:fs/promises'; +import { env } from '$env/dynamic/private'; +import { + getMssqlPoolMaster, + resolveNodeSqlPassword, + queryDatabaseMetricsOnServer, + listUserDatabasesOnServer, + backupDatabaseOnServer, + dropDatabaseOnServer +} from './mssql-nodes'; +import { + getRestoreTargetWithSecretsById, + listDatabaseNodesForMssql +} from './controldesk-pg'; +import { ddlTimeoutMs, indexNodesByDbName, normalizeServerHost, sizeTolerance } from './dedup-databases'; +import { + joinRemotePath, + sftpDownload, + sftpUploadAtomic, + sftpDelete, + zipSingleFile, + type SftpCreds +} from './sftp-transfer'; +import { logger } from './logger'; + +function verifyTimeoutMs(): number { + const v = Number(env.PANEL_DEDUP_MOVE_VERIFY_TIMEOUT_MS); + return Number.isFinite(v) && v > 0 ? v : 180000; // 3 min por defecto +} +function verifyPollMs(): number { + const v = Number(env.PANEL_DEDUP_MOVE_POLL_MS); + return Number.isFinite(v) && v >= 1000 ? v : 5000; +} +/** Carpeta donde el SQL viejo escribe el .bak (default: data_folder del restore_target viejo). */ +function backupFolderFor(dataFolder: string): string { + const override = String(env.PANEL_DEDUP_BACKUP_FOLDER || '').trim(); + return override || dataFolder; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** Nombre base del archivo (stem) que CRA usa para mapear el respaldo a su nodo. */ +export function backupStemForNode(node: any, fallbackDbName: string): string { + return String(node?.NodoSubNodo ?? '').trim() || String(fallbackDbName ?? '').trim(); +} + +function credsOf(t: { ssh_host: string; ssh_port: number; ssh_username: string; ssh_password: string }): SftpCreds { + return { host: t.ssh_host, port: t.ssh_port, username: t.ssh_username, password: t.ssh_password }; +} + +export type MoveStatus = 'movida_y_borrada' | 'en_transito' | 'ya_en_nuevo'; +export type MoveResult = { name: string; status: MoveStatus; message: string }; + +/** + * Mueve `dbName` del servidor viejo (`oldTargetId`) a su servidor nuevo asignado en el catálogo. + * Lanza si falta algún requisito (nodo, destino, credenciales) o si el backup/transferencia fallan. + */ +export async function moveDatabaseToNewServer( + oldTargetId: number, + dbName: string, + actor?: string +): Promise { + const oldTarget = await getRestoreTargetWithSecretsById(oldTargetId); + if (!oldTarget) throw new Error('Servidor viejo no encontrado.'); + if (!oldTarget.ssh_host || !oldTarget.ssh_username) { + throw new Error('El servidor viejo no tiene credenciales SSH configuradas.'); + } + + const oldPool = await getMssqlPoolMaster(oldTarget.server_ip, oldTarget.sql_password, oldTarget.sql_username); + // Pool aparte con timeout amplio para las operaciones largas (BACKUP y DROP) sobre el viejo. + const oldPoolDDL = await getMssqlPoolMaster( + oldTarget.server_ip, + oldTarget.sql_password, + oldTarget.sql_username, + ddlTimeoutMs() + ); + const oldDbs = await listUserDatabasesOnServer(oldPool); + const oldInfo = oldDbs.find((d) => d.name.toLowerCase() === dbName.trim().toLowerCase()); + if (!oldInfo) throw new Error(`La base "${dbName}" no existe en el servidor viejo.`); + const realName = oldInfo.name; + const allowedNames = new Set(oldDbs.map((d) => d.name)); + + const node = indexNodesByDbName(await listDatabaseNodesForMssql()).get(realName.toLowerCase()); + if (!node) throw new Error(`La base "${realName}" no tiene un nodo activo en el catálogo; no se puede enrutar.`); + + const newTargetId = Number(node.RestoreTargetId); + if (!Number.isInteger(newTargetId) || newTargetId <= 0) { + throw new Error('El nodo no tiene servidor de restauración asignado.'); + } + const newTarget = await getRestoreTargetWithSecretsById(newTargetId); + if (!newTarget) throw new Error('Servidor nuevo (destino) no encontrado.'); + if (!newTarget.ssh_host || !newTarget.ssh_username) { + throw new Error('El servidor nuevo no tiene credenciales SSH configuradas.'); + } + if (!newTarget.remote_inbox_path) { + throw new Error('El servidor nuevo no tiene carpeta de Entrada (remote_inbox_path) configurada.'); + } + if (normalizeServerHost(oldTarget.server_ip) === normalizeServerHost(newTarget.server_ip)) { + throw new Error('El destino nuevo es el mismo servidor viejo; no hay nada que mover.'); + } + + const tolerance = sizeTolerance(); + const newServer = String(node.ServerName || '').trim(); + const newPool = await getMssqlPoolMaster(newServer, resolveNodeSqlPassword(node.sql_password)); + + // Si ya existe en el nuevo, no re-enviamos (evita trabajo y sobrescrituras). + const already = await queryDatabaseMetricsOnServer(newPool, realName); + if (already) { + return { name: realName, status: 'ya_en_nuevo', message: 'La base ya existe en el servidor nuevo.' }; + } + + const stem = backupStemForNode(node, realName); + const bakName = `${stem}.bak`; + const zipName = `${stem}.zip`; + const bakRemoteOld = joinRemotePath(backupFolderFor(oldTarget.data_folder), bakName); + const zipRemoteNew = joinRemotePath(newTarget.remote_inbox_path, zipName); + + const workDir = await fs.mkdtemp(path.join(os.tmpdir(), 'dedup-move-')); + const localBak = path.join(workDir, bakName); + const localZip = path.join(workDir, zipName); + + try { + logger.info({ + message: 'dedup-move: iniciando', + context: { db: realName, from: oldTarget.server_ip, to: newTarget.server_ip, actor: actor ?? null } + }); + + await backupDatabaseOnServer(oldPoolDDL, realName, allowedNames, bakRemoteOld); + await sftpDownload(credsOf(oldTarget), bakRemoteOld, localBak); + await zipSingleFile(localBak, bakName, localZip); + await sftpUploadAtomic(credsOf(newTarget), localZip, zipRemoteNew); + + // Limpieza del .bak temporal en el viejo (best-effort, no aborta el flujo). + try { + await sftpDelete(credsOf(oldTarget), bakRemoteOld); + } catch (e) { + logger.warn({ + message: 'dedup-move: no se pudo borrar el .bak temporal del viejo', + context: { db: realName, path: bakRemoteOld, error: e instanceof Error ? e.message : String(e) } + }); + } + } finally { + await fs.rm(workDir, { recursive: true, force: true }).catch(() => {}); + } + + // Verificación acotada: esperar a que CRA restaure en el nuevo con tamaño coherente. + const deadline = Date.now() + verifyTimeoutMs(); + const threshold = oldInfo.size_mb * (1 - tolerance); + let confirmed = false; + while (Date.now() < deadline) { + await sleep(verifyPollMs()); + const info = await queryDatabaseMetricsOnServer(newPool, realName); + if (info && (Number(info.size_mb) || 0) >= threshold) { + confirmed = true; + break; + } + } + + if (!confirmed) { + logger.info({ + message: 'dedup-move: en tránsito (CRA aún no confirma)', + context: { db: realName, to: newTarget.server_ip } + }); + return { + name: realName, + status: 'en_transito', + message: 'Enviada al servidor nuevo. CloudRestoreAS la está restaurando; se borrará del viejo al confirmar (re-escanea).' + }; + } + + await dropDatabaseOnServer(oldPoolDDL, realName, allowedNames); + logger.info({ + message: 'dedup-move: movida y borrada del viejo', + context: { db: realName, from: oldTarget.server_ip, to: newTarget.server_ip, actor: actor ?? null } + }); + return { name: realName, status: 'movida_y_borrada', message: 'Movida al servidor nuevo y borrada del viejo.' }; +} diff --git a/src/lib/server/db.ts b/src/lib/server/db.ts index 552dacf..5836837 100644 --- a/src/lib/server/db.ts +++ b/src/lib/server/db.ts @@ -1,7 +1,12 @@ import pkg from 'pg'; -const { Pool } = pkg; +const { Pool, types } = pkg; import { env } from '$env/dynamic/private'; +// DATE (OID 1082): devolver el string 'YYYY-MM-DD' crudo. Por default pg lo parsea +// como Date en hora LOCAL del proceso, y un uso posterior de .toISOString() puede +// desfasar el dia segun el timezone del servidor (afecta a24c.database_nodes.anexo24c_aviso_fecha). +types.setTypeParser(1082, (val: string) => val); + // Pool de PostgreSQL para usuarios y permisos const pgPool = new Pool({ host: env.DB_POSTGRES_HOST || '10.0.20.152', diff --git a/src/lib/server/dedup-databases.test.ts b/src/lib/server/dedup-databases.test.ts new file mode 100644 index 0000000..7bd9658 --- /dev/null +++ b/src/lib/server/dedup-databases.test.ts @@ -0,0 +1,151 @@ +/** + * Pruebas de la lógica de depuración de bases duplicadas. Cubre la decisión pura de borrado + * (classifyDuplicate / isDeletable), la normalización de host para detectar "mismo servidor" y + * la validación por whitelist de dropDatabaseOnServer (defensa contra inyección/borrado indebido). + */ +import { describe, it, expect, vi } from 'vitest'; +import { + classifyDuplicate, + isDeletable, + normalizeServerHost, + resolveCatalogState, + DEFAULT_SIZE_TOLERANCE, + type DuplicateInput +} from './dedup-databases'; +import { backupStemForNode } from './db-move'; +import { dropDatabaseOnServer } from './mssql-nodes'; + +const base: DuplicateInput = { + catalogState: 'active', + sameServer: false, + newVerified: true, + newExists: true, + oldSizeMb: 1000, + newSizeMb: 1000 +}; + +describe('classifyDuplicate', () => { + const tol = DEFAULT_SIZE_TOLERANCE; // 0.2 -> el nuevo debe pesar >= 800 MB + + it('sin nodo en el catálogo no se sabe el destino -> no_catalogo', () => { + expect(classifyDuplicate({ ...base, catalogState: 'absent' }, tol)).toBe('no_catalogo'); + }); + + it('el nodo existe pero está desactivado -> nodo_desactivado', () => { + expect(classifyDuplicate({ ...base, catalogState: 'inactive' }, tol)).toBe('nodo_desactivado'); + }); + + it('nodo desactivado tiene precedencia aunque el nuevo no cuadre', () => { + expect( + classifyDuplicate({ ...base, catalogState: 'inactive', newExists: false, sameServer: true }, tol) + ).toBe('nodo_desactivado'); + }); + + it('el destino nuevo es el mismo servidor -> mismo_servidor (nunca borrable)', () => { + expect(classifyDuplicate({ ...base, sameServer: true }, tol)).toBe('mismo_servidor'); + }); + + it('no se pudo verificar el servidor nuevo -> error_nuevo', () => { + expect(classifyDuplicate({ ...base, newVerified: false }, tol)).toBe('error_nuevo'); + }); + + it('verificado pero la base no existe en el nuevo -> falta_en_nuevo', () => { + expect(classifyDuplicate({ ...base, newExists: false }, tol)).toBe('falta_en_nuevo'); + }); + + it('existe en el nuevo con tamaño coherente -> segura', () => { + expect(classifyDuplicate({ ...base, oldSizeMb: 1000, newSizeMb: 900 }, tol)).toBe('segura'); + }); + + it('el tamaño en el límite (>= 80%) sigue siendo segura', () => { + expect(classifyDuplicate({ ...base, oldSizeMb: 1000, newSizeMb: 800 }, tol)).toBe('segura'); + }); + + it('el nuevo pesa mucho menos que el viejo -> revisar (posible copia incompleta)', () => { + expect(classifyDuplicate({ ...base, oldSizeMb: 1000, newSizeMb: 500 }, tol)).toBe('revisar'); + }); + + it('base vacía en el viejo (0 MB): cualquier tamaño en el nuevo es coherente -> segura', () => { + expect(classifyDuplicate({ ...base, oldSizeMb: 0, newSizeMb: 0 }, tol)).toBe('segura'); + }); +}); + +describe('isDeletable', () => { + it('solo "segura" es borrable', () => { + expect(isDeletable('segura')).toBe(true); + for (const s of [ + 'revisar', + 'falta_en_nuevo', + 'nodo_desactivado', + 'no_catalogo', + 'mismo_servidor', + 'error_nuevo' + ] as const) { + expect(isDeletable(s)).toBe(false); + } + }); +}); + +describe('resolveCatalogState', () => { + const active = new Map([['ventasdb', { BDName: 'VentasDB' }]]); + const all = new Map([ + ['ventasdb', { BDName: 'VentasDB' }], + ['viejadb', { BDName: 'ViejaDB' }] // en el catálogo pero NO en activos -> desactivado + ]); + + it('activo si está en el índice de nodos activos', () => { + expect(resolveCatalogState('VentasDB', active, all)).toBe('active'); + }); + it('desactivado si está en el catálogo pero no entre los activos', () => { + expect(resolveCatalogState('ViejaDB', active, all)).toBe('inactive'); + }); + it('ausente si no está en el catálogo', () => { + expect(resolveCatalogState('OtraDB', active, all)).toBe('absent'); + }); +}); + +describe('backupStemForNode', () => { + it('prefiere node_subnode_key (NodoSubNodo) para que CRA enrute el respaldo', () => { + expect(backupStemForNode({ NodoSubNodo: 'NODO001', BDName: 'VentasDB' }, 'VentasDB')).toBe('NODO001'); + }); + it('cae al nombre de la base si no hay NodoSubNodo', () => { + expect(backupStemForNode({ NodoSubNodo: ' ' }, 'VentasDB')).toBe('VentasDB'); + }); +}); + +describe('normalizeServerHost', () => { + it('ignora mayúsculas y espacios, e iguala host,puerto equivalentes', () => { + expect(normalizeServerHost(' HOST01,1433 ')).toBe(normalizeServerHost('host01,1433')); + }); + + it('distingue host distinto y puerto distinto', () => { + expect(normalizeServerHost('host01,1433')).not.toBe(normalizeServerHost('host02,1433')); + expect(normalizeServerHost('host01,1433')).not.toBe(normalizeServerHost('host01,1434')); + }); +}); + +describe('dropDatabaseOnServer (guard de whitelist)', () => { + const allowed = new Set(['VentasDB', 'ComprasDB']); + + it('rechaza un nombre fuera de la whitelist ANTES de tocar el pool', async () => { + const pool = { request: vi.fn() } as any; + await expect(dropDatabaseOnServer(pool, 'master', allowed)).rejects.toThrow(/no permitida/i); + await expect(dropDatabaseOnServer(pool, '', allowed)).rejects.toThrow(/no permitida/i); + // Intento de inyección: el string completo no está en la whitelist, así que ni llega al pool. + await expect( + dropDatabaseOnServer(pool, 'VentasDB]; DROP DATABASE Otra;--', allowed) + ).rejects.toThrow(/no permitida/i); + expect(pool.request).not.toHaveBeenCalled(); + }); + + it('un nombre permitido llega al pool con @dbname parametrizado', async () => { + const query = vi.fn().mockResolvedValue({}); + const input = vi.fn(); + const request = vi.fn(() => ({ input, query })); + const pool = { request } as any; + await dropDatabaseOnServer(pool, 'VentasDB', allowed); + expect(input).toHaveBeenCalledWith('dbname', expect.anything(), 'VentasDB'); + expect(query).toHaveBeenCalledTimes(1); + expect(String(query.mock.calls[0][0])).toContain('QUOTENAME'); + }); +}); diff --git a/src/lib/server/dedup-databases.ts b/src/lib/server/dedup-databases.ts new file mode 100644 index 0000000..10e3927 --- /dev/null +++ b/src/lib/server/dedup-databases.ts @@ -0,0 +1,320 @@ +/** + * Depuración de bases duplicadas: al mover bases a un servidor nuevo, las copias quedaron + * también en el viejo. Aquí se reconcilia (¿la base ya está bien en el nuevo?) y se borran + * las copias del servidor viejo de forma segura. + * + * El servidor viejo se identifica por su restore_target; el nuevo es el `server_name` que el + * catálogo (database_nodes) tiene asignado a esa base. Criterio de "segura para borrar": + * existe en el nuevo con tamaño coherente (>= (1 - tolerancia) del tamaño en el viejo). + */ +import { env } from '$env/dynamic/private'; +import { + getMssqlPoolMaster, + resolveNodeSqlPassword, + queryDatabaseMetricsOnServer, + listUserDatabasesOnServer, + dropDatabaseOnServer, + parseMssqlServer, + mapWithConcurrency +} from './mssql-nodes'; +import { + getRestoreTargetWithPasswordById, + listDatabaseNodes, + listDatabaseNodesForMssql +} from './controldesk-pg'; +import { logger } from './logger'; + +export const DEFAULT_SIZE_TOLERANCE = 0.2; + +/** Tolerancia de tamaño (fracción 0–1). El nuevo debe pesar >= (1 - tolerancia) del viejo. */ +export function sizeTolerance(): number { + const v = Number(env.PANEL_DEDUP_SIZE_TOLERANCE); + return Number.isFinite(v) && v >= 0 && v < 1 ? v : DEFAULT_SIZE_TOLERANCE; +} + +/** + * requestTimeout para operaciones largas de SQL Server (BACKUP/DROP). El default de node-mssql + * (15 s) aborta un BACKUP/DROP real. Default 1 h, configurable. + */ +export function ddlTimeoutMs(): number { + const v = Number(env.PANEL_DEDUP_DDL_TIMEOUT_MS); + return Number.isFinite(v) && v > 0 ? v : 3600000; +} + +/** Estado de la base en el catálogo del panel (database_nodes). */ +export type CatalogState = 'active' | 'inactive' | 'absent'; + +export type DuplicateStatus = + | 'segura' // existe en el nuevo con tamaño coherente -> se puede borrar del viejo + | 'revisar' // existe en el nuevo pero el tamaño no cuadra + | 'falta_en_nuevo' // no existe en el servidor nuevo (candidata a "mandar al nuevo") + | 'nodo_desactivado' // la base está en el catálogo pero su nodo está desactivado + | 'no_catalogo' // la base no está en el catálogo (no se sabe su destino) + | 'mismo_servidor' // el destino nuevo ES este mismo servidor (evita borrar la copia viva) + | 'error_nuevo'; // no se pudo verificar el servidor nuevo (conexión/sin destino) + +export type DuplicateInput = { + catalogState: CatalogState; + sameServer: boolean; + newVerified: boolean; + newExists: boolean; + oldSizeMb: number; + newSizeMb: number; +}; + +/** + * Clasifica una base del servidor viejo. Pura y sin efectos: es el único punto que decide si una + * base es borrable, tanto en el escaneo como en la re-verificación previa al borrado. + */ +export function classifyDuplicate(inp: DuplicateInput, tolerance: number): DuplicateStatus { + if (inp.catalogState === 'absent') return 'no_catalogo'; + if (inp.catalogState === 'inactive') return 'nodo_desactivado'; + if (inp.sameServer) return 'mismo_servidor'; + if (!inp.newVerified) return 'error_nuevo'; + if (!inp.newExists) return 'falta_en_nuevo'; + const threshold = inp.oldSizeMb * (1 - tolerance); + return inp.newSizeMb >= threshold ? 'segura' : 'revisar'; +} + +export function isDeletable(status: DuplicateStatus): boolean { + return status === 'segura'; +} + +/** Normaliza `host,puerto` / `host\instancia` a una clave comparable para detectar mismo servidor. */ +export function normalizeServerHost(address: string): string { + const { server, port } = parseMssqlServer(String(address ?? '').trim()); + return `${server.toLowerCase()}|${port ?? ''}`; +} + +export type DuplicateRow = { + name: string; + oldSizeMb: number; + oldLastRestore: string | null; + newServer: string | null; + newServerLabel: string | null; + newSizeMb: number | null; + newLastRestore: string | null; + status: DuplicateStatus; + deletable: boolean; + /** true si la base es candidata a mandarse al servidor nuevo (falta_en_nuevo con nodo activo). */ + movable: boolean; +}; + +export type ScanResult = { + target: { id: number; name: string; server_ip: string }; + rows: DuplicateRow[]; +}; + +function toIso(value: unknown): string | null { + if (!value) return null; + const d = value instanceof Date ? value : new Date(value as string); + return Number.isNaN(d.getTime()) ? null : d.toISOString(); +} + +/** Indexa nodos del catálogo por nombre de base (minúsculas); conserva el primero. */ +export function indexNodesByDbName(nodes: any[]): Map { + const byName = new Map(); + for (const n of nodes) { + const key = String(n.BDName ?? '').trim().toLowerCase(); + if (key && !byName.has(key)) byName.set(key, n); + } + return byName; +} + +/** Estado en el catálogo: activo (con destino conectable), desactivado, o ausente. */ +export function resolveCatalogState( + dbName: string, + activeByName: Map, + allByName: Map +): CatalogState { + const key = dbName.toLowerCase(); + if (activeByName.has(key)) return 'active'; + if (allByName.has(key)) return 'inactive'; + return 'absent'; +} + +function nodeLabel(node: any): string | null { + return String(node?.Nombre ?? node?.NodoSubNodo ?? '').trim() || null; +} + +export type NewServerCheck = { + sameServer: boolean; + newVerified: boolean; + newExists: boolean; + newSizeMb: number | null; + newLastRestore: string | null; + newServer: string | null; +}; + +const EMPTY_NEW_CHECK: NewServerCheck = { + sameServer: false, + newVerified: false, + newExists: false, + newSizeMb: null, + newLastRestore: null, + newServer: null +}; + +/** Verifica la copia en el servidor nuevo asignado a `node` para una base dada. */ +export async function verifyOnNewServer( + node: any, + dbName: string, + oldHost: string +): Promise { + const newServer = String(node?.ServerName ?? '').trim(); + const sameServer = !!newServer && normalizeServerHost(newServer) === oldHost; + if (sameServer || !newServer) { + return { ...EMPTY_NEW_CHECK, sameServer, newServer: newServer || null }; + } + try { + const pool = await getMssqlPoolMaster(newServer, resolveNodeSqlPassword(node.sql_password)); + const info = await queryDatabaseMetricsOnServer(pool, dbName); + return { + sameServer: false, + newVerified: true, + newExists: !!info, + newSizeMb: info ? Number(info.size_mb) || 0 : null, + newLastRestore: info ? toIso(info.last_restore_date) : null, + newServer + }; + } catch (e) { + logger.error({ + message: 'dedup: no se pudo verificar el servidor nuevo', + context: { db: dbName, server: newServer, error: e instanceof Error ? e.message : String(e) } + }); + return { ...EMPTY_NEW_CHECK, newServer }; + } +} + +/** + * Escanea el servidor viejo (restore_target) y reconcilia cada base de usuario contra su destino + * nuevo en el catálogo. No borra nada. + */ +export async function scanDuplicates(oldTargetId: number): Promise { + const target = await getRestoreTargetWithPasswordById(oldTargetId); + if (!target) throw new Error('Servidor de restauración no encontrado.'); + + const tolerance = sizeTolerance(); + const oldHost = normalizeServerHost(target.server_ip); + const oldPool = await getMssqlPoolMaster(target.server_ip, target.sql_password, target.sql_username); + const oldDbs = await listUserDatabasesOnServer(oldPool); + // Nodos activos (con sql_password, para conectar al nuevo) + TODOS los nodos (para distinguir + // los que están en el catálogo pero desactivados de los que no están en absoluto). + const activeByName = indexNodesByDbName(await listDatabaseNodesForMssql()); + const allByName = indexNodesByDbName(await listDatabaseNodes()); + + const rows = await mapWithConcurrency(oldDbs, 8, async (oldDb): Promise => { + const key = oldDb.name.toLowerCase(); + const catalogState = resolveCatalogState(oldDb.name, activeByName, allByName); + const activeNode = activeByName.get(key); + const anyNode = allByName.get(key); + const v = + catalogState === 'active' && activeNode + ? await verifyOnNewServer(activeNode, oldDb.name, oldHost) + : EMPTY_NEW_CHECK; + const status = classifyDuplicate( + { + catalogState, + sameServer: v.sameServer, + newVerified: v.newVerified, + newExists: v.newExists, + oldSizeMb: oldDb.size_mb, + newSizeMb: v.newSizeMb ?? 0 + }, + tolerance + ); + return { + name: oldDb.name, + oldSizeMb: oldDb.size_mb, + oldLastRestore: toIso(oldDb.last_restore_date), + newServer: v.newServer ?? (anyNode ? String(anyNode.ServerName ?? '').trim() || null : null), + newServerLabel: nodeLabel(activeNode ?? anyNode), + newSizeMb: v.newSizeMb, + newLastRestore: v.newLastRestore, + status, + deletable: isDeletable(status), + movable: status === 'falta_en_nuevo' + }; + }); + + return { target: { id: target.id, name: target.name, server_ip: target.server_ip }, rows }; +} + +export type DropOutcome = { name: string; ok: boolean; status: string; message?: string }; + +/** + * Borra del servidor viejo las bases indicadas, RE-VERIFICANDO la seguridad del lado servidor + * (no confía en el cliente): solo borra las que siguen clasificando como 'segura'. + */ +export async function dropDuplicates( + oldTargetId: number, + names: string[], + actor?: string +): Promise { + const target = await getRestoreTargetWithPasswordById(oldTargetId); + if (!target) throw new Error('Servidor de restauración no encontrado.'); + + const tolerance = sizeTolerance(); + const oldHost = normalizeServerHost(target.server_ip); + const oldPool = await getMssqlPoolMaster(target.server_ip, target.sql_password, target.sql_username); + // Pool con timeout amplio para el DROP (SINGLE_USER + ROLLBACK puede pasar de 15 s). + const oldPoolDDL = await getMssqlPoolMaster( + target.server_ip, + target.sql_password, + target.sql_username, + ddlTimeoutMs() + ); + const oldDbs = await listUserDatabasesOnServer(oldPool); + const oldByName = new Map(oldDbs.map((d) => [d.name.toLowerCase(), d])); + const allowedNames = new Set(oldDbs.map((d) => d.name)); // whitelist exacta del propio servidor + const activeByName = indexNodesByDbName(await listDatabaseNodesForMssql()); + const allByName = indexNodesByDbName(await listDatabaseNodes()); + + const outcomes: DropOutcome[] = []; + for (const rawName of names) { + const name = String(rawName ?? '').trim(); + const oldInfo = oldByName.get(name.toLowerCase()); + if (!oldInfo) { + outcomes.push({ name, ok: false, status: 'no_existe', message: 'La base ya no existe en el servidor viejo.' }); + continue; + } + const realName = oldInfo.name; // casing canónico del servidor + const catalogState = resolveCatalogState(realName, activeByName, allByName); + const activeNode = activeByName.get(realName.toLowerCase()); + const v = + catalogState === 'active' && activeNode + ? await verifyOnNewServer(activeNode, realName, oldHost) + : EMPTY_NEW_CHECK; + const status = classifyDuplicate( + { + catalogState, + sameServer: v.sameServer, + newVerified: v.newVerified, + newExists: v.newExists, + oldSizeMb: oldInfo.size_mb, + newSizeMb: v.newSizeMb ?? 0 + }, + tolerance + ); + if (!isDeletable(status)) { + outcomes.push({ name: realName, ok: false, status, message: 'No pasó la verificación de seguridad; no se borró.' }); + continue; + } + try { + await dropDatabaseOnServer(oldPoolDDL, realName, allowedNames); + logger.info({ + message: 'dedup: base borrada del servidor viejo', + context: { db: realName, server: target.server_ip, target_id: target.id, actor: actor ?? null } + }); + outcomes.push({ name: realName, ok: true, status: 'borrada' }); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + logger.error({ + message: 'dedup: fallo al borrar base del servidor viejo', + context: { db: realName, server: target.server_ip, error: msg } + }); + outcomes.push({ name: realName, ok: false, status: 'error_drop', message: msg }); + } + } + return outcomes; +} diff --git a/src/lib/server/fernet.test.ts b/src/lib/server/fernet.test.ts new file mode 100644 index 0000000..17286ec --- /dev/null +++ b/src/lib/server/fernet.test.ts @@ -0,0 +1,74 @@ +/** + * Pruebas del esquema Fernet, compatible con `cryptography.fernet.Fernet` de a24c. + * Cubre: round-trip, detección de token, HMAC, clave errónea y derivación de clave. + */ +import { describe, it, expect } from 'vitest'; +import { + deriveFernetKey, + fernetEncrypt, + fernetDecrypt, + isFernetToken +} from './fernet'; + +const SECRET = 'clave-secreta-de-prueba-compartida-con-a24c'; +const KEY = deriveFernetKey(SECRET); +const TS = 1_700_000_000; // segundos Unix fijos para determinismo + +describe('fernet (interop a24c)', () => { + it('deriva una clave de 32 bytes desde SECRET_KEY (sha256)', () => { + expect(KEY.length).toBe(32); + // La misma SECRET_KEY produce siempre la misma clave + expect(deriveFernetKey(SECRET).equals(KEY)).toBe(true); + }); + + it('lanza si SECRET_KEY está vacía', () => { + expect(() => deriveFernetKey('')).toThrow(/SECRET_KEY/); + expect(() => deriveFernetKey(' ')).toThrow(/SECRET_KEY/); + }); + + it('round-trip: descifrar devuelve el texto original (incl. UTF-8)', () => { + for (const pt of ['Soluciones01!', 'ClaveConÑ_áé#2024', '']) { + const token = fernetEncrypt(pt, KEY, TS); + expect(fernetDecrypt(token, KEY)).toBe(pt); + } + }); + + it('produce un token Fernet reconocible (prefijo gAAAAA, base64-url)', () => { + const token = fernetEncrypt('secreto', KEY, TS); + expect(token.startsWith('gAAAAA')).toBe(true); + expect(isFernetToken(token)).toBe(true); + }); + + it('isFernetToken rechaza texto plano y valores no-token', () => { + expect(isFernetToken('Soluciones01!')).toBe(false); + expect(isFernetToken('gcm:a:b:c')).toBe(false); + expect(isFernetToken('')).toBe(false); + expect(isFernetToken(null)).toBe(false); + expect(isFernetToken(undefined)).toBe(false); + }); + + it('usa IV aleatorio: dos cifrados difieren pero descifran igual', () => { + const a = fernetEncrypt('mismo', KEY, TS); + const b = fernetEncrypt('mismo', KEY, TS); + expect(a).not.toBe(b); + expect(fernetDecrypt(a, KEY)).toBe(fernetDecrypt(b, KEY)); + }); + + it('falla con clave incorrecta (HMAC no valida)', () => { + const token = fernetEncrypt('secreto', KEY, TS); + const otherKey = deriveFernetKey('otra-secret-key'); + expect(() => fernetDecrypt(token, otherKey)).toThrow(/HMAC/); + }); + + it('detecta manipulación del token', () => { + const token = fernetEncrypt('integridad', KEY, TS); + const data = Buffer.from(token.replace(/-/g, '+').replace(/_/g, '/'), 'base64'); + data[20] = data[20] ^ 0xff; // altera un byte del ciphertext + const tampered = data.toString('base64').replace(/\+/g, '-').replace(/\//g, '_'); + expect(() => fernetDecrypt(tampered, KEY)).toThrow(); + }); + + it('rechaza tokens demasiado cortos o con versión inválida', () => { + expect(() => fernetDecrypt('gA==', KEY)).toThrow(/corto/); + }); +}); diff --git a/src/lib/server/fernet.ts b/src/lib/server/fernet.ts new file mode 100644 index 0000000..2b536fc --- /dev/null +++ b/src/lib/server/fernet.ts @@ -0,0 +1,115 @@ +/** + * Implementación pura del esquema Fernet (spec oficial), compatible byte a byte con + * `cryptography.fernet.Fernet` de Python — que es lo que usa a24c para descifrar la + * contraseña SQL de cada nodo (`database_nodes.sql_password`). + * + * Fernet = AES-128-CBC (PKCS7) + HMAC-SHA256, sobre una clave de 32 bytes: + * - bytes [0..16) → clave de firma (HMAC) + * - bytes [16..32) → clave de cifrado (AES-128) + * + * Formato del token (antes de base64-url): + * 0x80 | timestamp(8, big-endian) | iv(16) | ciphertext(múltiplo de 16) | hmac(32) + * + * a24c deriva la clave así: base64.urlsafe_b64encode(sha256(SECRET_KEY).digest()) + * que Fernet vuelve a decodificar a los 32 bytes crudos de `sha256(SECRET_KEY)`. + * Aquí replicamos exactamente esa derivación con `deriveFernetKey`. + */ +import { + createCipheriv, + createDecipheriv, + createHash, + createHmac, + randomBytes, + timingSafeEqual +} from 'node:crypto'; + +const FERNET_VERSION = 0x80; +const KEY_LENGTH = 32; // 16 firma + 16 cifrado +const IV_LENGTH = 16; +const HMAC_LENGTH = 32; +const HEADER_LENGTH = 1 + 8 + IV_LENGTH; // version + timestamp + iv +/** Longitud mínima de un token válido: header + 1 bloque AES + hmac. */ +const MIN_TOKEN_BYTES = HEADER_LENGTH + 16 + HMAC_LENGTH; + +function toUrlSafeBase64(buf: Buffer): string { + // Padded url-safe base64 (con `=`), como produce Python; su decoder lo exige. + return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_'); +} + +function fromUrlSafeBase64(token: string): Buffer { + return Buffer.from(token.replace(/-/g, '+').replace(/_/g, '/'), 'base64'); +} + +/** Deriva la clave Fernet de 32 bytes desde SECRET_KEY, idéntica a la de a24c. */ +export function deriveFernetKey(secret: string): Buffer { + if (!secret || !secret.trim()) { + throw new Error('SECRET_KEY no está configurada (debe coincidir con la de a24c).'); + } + return createHash('sha256').update(secret, 'utf8').digest(); // 32 bytes +} + +/** + * Cifra `plaintext` como token Fernet. `timestampSec` permite inyectar el tiempo + * (segundos Unix) para pruebas deterministas; en producción se pasa el reloj real. + */ +export function fernetEncrypt(plaintext: string, key32: Buffer, timestampSec: number): string { + if (key32.length !== KEY_LENGTH) { + throw new Error(`La clave Fernet debe ser de ${KEY_LENGTH} bytes; se recibieron ${key32.length}.`); + } + const signingKey = key32.subarray(0, 16); + const encKey = key32.subarray(16, 32); + + const iv = randomBytes(IV_LENGTH); + const ts = Buffer.alloc(8); + ts.writeBigUInt64BE(BigInt(Math.floor(timestampSec))); + + const cipher = createCipheriv('aes-128-cbc', encKey, iv); // PKCS7 automático + const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); + + const parts = Buffer.concat([Buffer.from([FERNET_VERSION]), ts, iv, ciphertext]); + const hmac = createHmac('sha256', signingKey).update(parts).digest(); + + return toUrlSafeBase64(Buffer.concat([parts, hmac])); +} + +/** Descifra un token Fernet. Lanza si el HMAC no valida o el formato es inválido. */ +export function fernetDecrypt(token: string, key32: Buffer): string { + if (key32.length !== KEY_LENGTH) { + throw new Error(`La clave Fernet debe ser de ${KEY_LENGTH} bytes; se recibieron ${key32.length}.`); + } + const signingKey = key32.subarray(0, 16); + const encKey = key32.subarray(16, 32); + + const data = fromUrlSafeBase64(String(token ?? '')); + if (data.length < MIN_TOKEN_BYTES) throw new Error('Token Fernet demasiado corto.'); + if (data[0] !== FERNET_VERSION) throw new Error('Versión de token Fernet inválida.'); + + const hmacOffset = data.length - HMAC_LENGTH; + const signed = data.subarray(0, hmacOffset); + const providedHmac = data.subarray(hmacOffset); + const expectedHmac = createHmac('sha256', signingKey).update(signed).digest(); + if (!timingSafeEqual(providedHmac, expectedHmac)) { + throw new Error('HMAC del token Fernet no coincide (clave incorrecta o dato manipulado).'); + } + + const iv = data.subarray(9, 9 + IV_LENGTH); + const ciphertext = data.subarray(9 + IV_LENGTH, hmacOffset); + const decipher = createDecipheriv('aes-128-cbc', encKey, iv); + return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8'); +} + +/** + * Heurística sin clave para distinguir un token Fernet de texto plano legado: + * base64-url válido que decodifica a un blob con versión 0x80 y longitud mínima. + */ +export function isFernetToken(value: unknown): boolean { + if (typeof value !== 'string') return false; + const v = value.trim(); + if (v.length < 100 || !/^[A-Za-z0-9_-]+={0,2}$/.test(v)) return false; + try { + const data = fromUrlSafeBase64(v); + return data.length >= MIN_TOKEN_BYTES && data[0] === FERNET_VERSION; + } catch { + return false; + } +} diff --git a/src/lib/server/mssql-nodes.test.ts b/src/lib/server/mssql-nodes.test.ts index b3560fd..7dc9bd6 100644 --- a/src/lib/server/mssql-nodes.test.ts +++ b/src/lib/server/mssql-nodes.test.ts @@ -14,7 +14,7 @@ vi.mock('./crypto', async (importOriginal) => { }); import { decryptSecret } from './crypto'; -import { resolveNodeSqlPassword } from './mssql-nodes'; +import { resolveNodeSqlPassword, parseMssqlServer } from './mssql-nodes'; const decryptMock = vi.mocked(decryptSecret); @@ -52,3 +52,35 @@ describe('resolveNodeSqlPassword', () => { expect(decryptMock).toHaveBeenCalledOnce(); }); }); + +describe('parseMssqlServer', () => { + it('host,puerto → server y port separados (evita el bug host,puerto:1433)', () => { + expect(parseMssqlServer('192.168.1.20,1433')).toEqual({ + server: '192.168.1.20', + port: 1433 + }); + }); + + it('solo host → sin puerto (tedious usa 1433 por defecto)', () => { + expect(parseMssqlServer('SQLSERVER01')).toEqual({ server: 'SQLSERVER01' }); + }); + + it('host\\instancia → instanceName', () => { + expect(parseMssqlServer('HOST\\SQLEXPRESS')).toEqual({ + server: 'HOST', + instanceName: 'SQLEXPRESS' + }); + }); + + it('host\\instancia,puerto → server, instanceName y port', () => { + expect(parseMssqlServer('HOST\\SQLEXPRESS,1450')).toEqual({ + server: 'HOST', + port: 1450, + instanceName: 'SQLEXPRESS' + }); + }); + + it('recorta espacios e ignora puerto no numérico', () => { + expect(parseMssqlServer(' 10.0.0.5 , abc ')).toEqual({ server: '10.0.0.5' }); + }); +}); diff --git a/src/lib/server/mssql-nodes.ts b/src/lib/server/mssql-nodes.ts index aeb1fa5..6a58560 100644 --- a/src/lib/server/mssql-nodes.ts +++ b/src/lib/server/mssql-nodes.ts @@ -36,6 +36,32 @@ export function adjustMssqlServerForDocker(serverName: string): string { return serverName.trim(); } +/** + * Descompone una dirección estilo SQL Server (`host,puerto` / `host\instancia` / combinaciones) + * en los campos separados que espera node-mssql (tedious). Es imprescindible: tedious NO entiende + * `host,puerto` en el campo `server` — lo toma como nombre de host literal y le añade el puerto + * por defecto (1433), produciendo intentos de conexión a `host,puerto:1433`. + */ +export function parseMssqlServer(address: string): { + server: string; + port?: number; + instanceName?: string; +} { + const raw = String(address ?? '').trim(); + const commaIdx = raw.indexOf(','); + const left = (commaIdx >= 0 ? raw.slice(0, commaIdx) : raw).trim(); + const portStr = commaIdx >= 0 ? raw.slice(commaIdx + 1).trim() : ''; + + const [host, instance] = left.split('\\', 2); + const result: { server: string; port?: number; instanceName?: string } = { + server: host.trim() + }; + const port = Number(portStr); + if (portStr && Number.isInteger(port) && port > 0) result.port = port; + if (instance && instance.trim()) result.instanceName = instance.trim(); + return result; +} + export function resolveMssqlUser(): string { return ( String(env.PANEL_MSSQL_USER || '').trim() @@ -63,8 +89,8 @@ export function resolveNodeSqlPassword(nodeSqlPassword: string | null | undefine ).trim(); } -function poolCacheKey(server: string, user: string, password: string): string { - return `${server}\t${user}\t${password}`; +function poolCacheKey(server: string, user: string, password: string, requestTimeoutMs?: number): string { + return `${server}\t${user}\t${password}\t${requestTimeoutMs ?? ''}`; } async function evictPoolIfNeeded(): Promise { @@ -85,7 +111,7 @@ async function evictPoolIfNeeded(): Promise { * Ejecuta `fn` sobre cada elemento con un máximo de `limit` tareas simultáneas. * Conserva el orden de `items` en el arreglo de resultados. */ -async function mapWithConcurrency( +export async function mapWithConcurrency( items: T[], limit: number, fn: (item: T, index: number) => Promise @@ -106,15 +132,24 @@ async function mapWithConcurrency( /** * Pool conectado a `master` en el servidor del nodo (permite consultar cualquier BD con nombre de tres partes). */ -export async function getMssqlPoolMaster(serverHost: string, password: string): Promise { - const user = resolveMssqlUser(); +export async function getMssqlPoolMaster( + serverHost: string, + password: string, + userOverride?: string, + requestTimeoutMs?: number +): Promise { + // El dashboard usa el usuario global (PANEL_MSSQL_USER); la depuración de duplicados conecta + // al servidor viejo con el usuario propio del restore_target, de ahí el override opcional. + const user = (userOverride && userOverride.trim()) || resolveMssqlUser(); if (!user || !password) { throw new Error( 'Falta PANEL_MSSQL_USER / PANEL_MSSQL_PASSWORD (o sql_password en database_nodes).' ); } const server = adjustMssqlServerForDocker(serverHost); - const key = poolCacheKey(server, user, password); + // El requestTimeout entra en la clave de caché: las operaciones largas (BACKUP/DROP) usan un + // pool distinto con timeout amplio, sin alterar el pool de consultas rápidas del dashboard. + const key = poolCacheKey(server, user, password, requestTimeoutMs); const existing = poolMap.get(key); if (existing) { @@ -127,18 +162,26 @@ export async function getMssqlPoolMaster(serverHost: string, password: string): poolMap.delete(key); } + // `server` puede venir como `host,puerto` (formato SQL Server); tedious necesita host y puerto + // en campos separados, o intentará conectar a `host,puerto:1433`. + const { server: host, port, instanceName } = parseMssqlServer(server); const cfg: sql.config = { user, password, - server, + server: host, + ...(port ? { port } : {}), database: 'master', // node-mssql gobierna el timeout de conexión con `connectionTimeout` (top-level); // se replica en options.connectTimeout (tedious) para cubrir ambas rutas. connectionTimeout: MSSQL_CONNECT_TIMEOUT_MS, + // requestTimeout por defecto de node-mssql es 15 s: insuficiente para BACKUP/DROP de bases + // reales. Cuando se pide, se amplía (el dashboard sigue con el default corto). + ...(requestTimeoutMs ? { requestTimeout: requestTimeoutMs } : {}), options: { encrypt: true, trustServerCertificate: true, - connectTimeout: MSSQL_CONNECT_TIMEOUT_MS + connectTimeout: MSSQL_CONNECT_TIMEOUT_MS, + ...(instanceName ? { instanceName } : {}) } }; @@ -286,6 +329,108 @@ function computeEffectivenessFromHistory( return effectivenessByDb; } +export type ServerDatabaseInfo = { + name: string; + size_mb: number; + last_restore_date: Date | null; + state_desc: string; + create_date: Date | null; +}; + +/** + * Lista las bases de USUARIO de un servidor (pool a master), con tamaño y último restore. + * Excluye las de sistema (database_id <= 4: master/tempdb/model/msdb). + */ +export async function listUserDatabasesOnServer( + pool: sql.ConnectionPool +): Promise { + const result = await pool.request().query(` + SELECT + d.name AS name, + CAST(( + SELECT SUM(mf.size) * 8.0 / 1024 + FROM sys.master_files mf + WHERE mf.database_id = d.database_id + ) AS DECIMAL(18,2)) AS size_mb, + ( + SELECT MAX(rh.restore_date) + FROM msdb.dbo.restorehistory rh + WHERE rh.destination_database_name = d.name + ) AS last_restore_date, + d.state_desc, + d.create_date + FROM sys.databases d + WHERE d.database_id > 4 + ORDER BY d.name + `); + return (result.recordset as any[]).map((r) => ({ + name: String(r.name), + size_mb: Number(r.size_mb) || 0, + last_restore_date: r.last_restore_date ?? null, + state_desc: String(r.state_desc ?? ''), + create_date: r.create_date ?? null + })); +} + +/** + * Borra una base en el servidor del pool. Fuerza SINGLE_USER (WITH ROLLBACK IMMEDIATE, cierra + * conexiones activas) y luego DROP DATABASE. + * + * Un identificador T-SQL NO se puede parametrizar, así que hay doble defensa contra inyección: + * (1) `databaseName` debe estar en `allowedNames` (lista real leída del propio servidor) y + * (2) dentro del batch se escapa con QUOTENAME. La validación ocurre ANTES de tocar el pool. + */ +export async function dropDatabaseOnServer( + pool: sql.ConnectionPool, + databaseName: string, + allowedNames: Set +): Promise { + const name = String(databaseName ?? '').trim(); + if (!name || !allowedNames.has(name)) { + throw new Error(`Base no permitida para borrado: "${name}".`); + } + const req = pool.request(); + req.input('dbname', sql.NVarChar(128), name); + await req.query(` + IF DB_ID(@dbname) IS NULL + THROW 50000, 'La base ya no existe en este servidor.', 1; + DECLARE @stmt NVARCHAR(MAX) = + N'ALTER DATABASE ' + QUOTENAME(@dbname) + N' SET SINGLE_USER WITH ROLLBACK IMMEDIATE;' + + N'DROP DATABASE ' + QUOTENAME(@dbname) + N';'; + EXEC sys.sp_executesql @stmt; + `); +} + +/** + * Respalda una base a `destPath` en el servidor del pool (COPY_ONLY para no romper la cadena de + * respaldos del cliente). El identificador NO se puede parametrizar: se valida contra `allowedNames` + * (lista real del servidor) y se escapa con QUOTENAME; la ruta destino SÍ va como parámetro. + */ +export async function backupDatabaseOnServer( + pool: sql.ConnectionPool, + databaseName: string, + allowedNames: Set, + destPath: string +): Promise { + const name = String(databaseName ?? '').trim(); + if (!name || !allowedNames.has(name)) { + throw new Error(`Base no permitida para respaldo: "${name}".`); + } + const dest = String(destPath ?? '').trim(); + if (!dest) throw new Error('Ruta de respaldo vacía.'); + const req = pool.request(); + req.input('dbname', sql.NVarChar(128), name); + req.input('dest', sql.NVarChar(4000), dest); + await req.query(` + IF DB_ID(@dbname) IS NULL + THROW 50000, 'La base no existe en este servidor.', 1; + DECLARE @stmt NVARCHAR(MAX) = + N'BACKUP DATABASE ' + QUOTENAME(@dbname) + + N' TO DISK = @p_dest WITH COPY_ONLY, INIT, FORMAT, NAME = N''dedup-move'';'; + EXEC sys.sp_executesql @stmt, N'@p_dest NVARCHAR(4000)', @p_dest = @dest; + `); +} + export type SqlDashboardBundle = { databaseRows: any[]; summaryMain: { total_databases: number; total_size_gb: number }; @@ -318,7 +463,9 @@ export async function loadSqlDashboardFromNodes(nodes: CatalogNodeRow[]): Promis queryDatabaseAlertRow(pool, dbn), queryRestoreHistoryForDatabase(pool, dbn) ]); - if (!row) return null; + // Conexión al servidor OK pero la base no existe en él: se marca como alerta + // "no encontrada" (sin métricas ni días sin sincronizar), no se descarta el nodo. + if (!row) return { node, dbn, row: null, notFound: true, alertRow: null, hist: [] }; return { node, dbn, row, alertRow, hist }; } catch (e) { console.error( @@ -335,6 +482,17 @@ export async function loadSqlDashboardFromNodes(nodes: CatalogNodeRow[]): Promis if (!res) continue; const { node, dbn, row, alertRow, hist } = res; + // Base no encontrada en el servidor: solo alerta (sin fila de métricas ni tamaño). + // last_restore_date en null => la UI muestra los días sin sincronizar como N/D. + if ((res as any).notFound) { + alertsData.push({ + visible_name: dbn, + last_restore_date: null, + not_found: true + }); + continue; + } + const visible = String(row.visible_name ?? dbn); const keyLower = visible.toLowerCase(); diff --git a/src/lib/server/sftp-transfer.test.ts b/src/lib/server/sftp-transfer.test.ts new file mode 100644 index 0000000..e6b810c --- /dev/null +++ b/src/lib/server/sftp-transfer.test.ts @@ -0,0 +1,31 @@ +/** + * Pruebas de las funciones puras de armado de rutas para la transferencia SFTP. La I/O real + * (SFTP/zip) no se prueba aquí; se cubre la construcción de rutas que es donde vive el riesgo + * de separadores Windows/POSIX. + */ +import { describe, it, expect } from 'vitest'; +import { joinRemotePath, toSftpPath } from './sftp-transfer'; + +describe('joinRemotePath', () => { + it('usa backslash cuando la carpeta es estilo Windows', () => { + expect(joinRemotePath('D:\\SQLDATA', 'NODO001.bak')).toBe('D:\\SQLDATA\\NODO001.bak'); + }); + + it('respeta separadores finales duplicados', () => { + expect(joinRemotePath('D:\\SQLDATA\\\\', 'a.zip')).toBe('D:\\SQLDATA\\a.zip'); + }); + + it('usa slash cuando la carpeta es POSIX', () => { + expect(joinRemotePath('/var/inbox/', 'a.zip')).toBe('/var/inbox/a.zip'); + }); +}); + +describe('toSftpPath', () => { + it('convierte backslashes de Windows a slashes para OpenSSH SFTP', () => { + expect(toSftpPath('D:\\SQLDATA\\NODO001.bak')).toBe('D:/SQLDATA/NODO001.bak'); + }); + + it('deja intactas las rutas POSIX', () => { + expect(toSftpPath('/var/inbox/a.zip')).toBe('/var/inbox/a.zip'); + }); +}); diff --git a/src/lib/server/sftp-transfer.ts b/src/lib/server/sftp-transfer.ts new file mode 100644 index 0000000..bde5a40 --- /dev/null +++ b/src/lib/server/sftp-transfer.ts @@ -0,0 +1,103 @@ +/** + * Transferencia de respaldos entre servidores por SFTP, usando las credenciales SSH que ya guarda + * cada restore_target. Se usa para mover una base del servidor viejo al nuevo: bajar el .bak del + * viejo, comprimirlo y subir el .zip a la carpeta de Entrada del nuevo (donde CloudRestoreAS lo + * restaura). Las funciones de armado de rutas son puras (probadas aparte). + */ +import { createWriteStream } from 'node:fs'; +import Client from 'ssh2-sftp-client'; +import archiver from 'archiver'; + +export type SftpCreds = { + host: string; + port: number; + username: string; + password: string; +}; + +const SFTP_READY_TIMEOUT_MS = 20000; + +/** Une carpeta + nombre respetando el separador dominante (Windows `\` o POSIX `/`). */ +export function joinRemotePath(folder: string, name: string): string { + const raw = String(folder ?? '').trim(); + const sep = raw.includes('\\') ? '\\' : '/'; + const trimmed = raw.replace(/[\\/]+$/, ''); + return `${trimmed}${sep}${name}`; +} + +/** Convierte una ruta Windows (`D:\x\y`) a la forma con `/` que acepta OpenSSH SFTP. */ +export function toSftpPath(p: string): string { + return String(p ?? '').replace(/\\/g, '/'); +} + +/** Abre una sesión SFTP, ejecuta `fn` y siempre cierra la conexión. */ +export async function withSftp(creds: SftpCreds, fn: (sftp: Client) => Promise): Promise { + const client = new Client(); + try { + await client.connect({ + host: creds.host, + port: creds.port || 22, + username: creds.username, + password: creds.password, + readyTimeout: SFTP_READY_TIMEOUT_MS + }); + return await fn(client); + } finally { + try { + await client.end(); + } catch { + /* ignore */ + } + } +} + +/** Baja un archivo remoto a una ruta local. */ +export async function sftpDownload(creds: SftpCreds, remotePath: string, localPath: string): Promise { + await withSftp(creds, (sftp) => sftp.fastGet(toSftpPath(remotePath), localPath)); +} + +/** + * Sube un archivo de forma atómica: primero a `.part` y luego rename a ``, para + * que CloudRestoreAS nunca vea un archivo a medio escribir en su carpeta de Entrada. + * + * El rename usa `posix-rename@openssh.com` (posixRename), que SOBRESCRIBE el destino: el + * SSH_FXP_RENAME estándar de OpenSSH falla si el `.zip` ya existe (de un intento previo o de una + * copia sin consumir). Si el servidor no tuviera la extensión, se cae a borrar-y-renombrar. + */ +export async function sftpUploadAtomic(creds: SftpCreds, localPath: string, remotePath: string): Promise { + const finalPath = toSftpPath(remotePath); + const tmpPath = `${finalPath}.part`; + await withSftp(creds, async (sftp) => { + await sftp.fastPut(localPath, tmpPath); + try { + await sftp.posixRename(tmpPath, finalPath); + } catch { + // Servidor sin posix-rename: borrar el destino (si existe) y renombrar clásico. + try { + await sftp.delete(finalPath); + } catch { + /* no existía */ + } + await sftp.rename(tmpPath, finalPath); + } + }); +} + +/** Borra un archivo remoto (limpieza del .bak temporal en el servidor viejo). */ +export async function sftpDelete(creds: SftpCreds, remotePath: string): Promise { + await withSftp(creds, (sftp) => sftp.delete(toSftpPath(remotePath))); +} + +/** Comprime un único archivo en un .zip con el nombre de entrada indicado. */ +export async function zipSingleFile(srcPath: string, entryName: string, destZipPath: string): Promise { + await new Promise((resolve, reject) => { + const output = createWriteStream(destZipPath); + const archive = archiver('zip', { zlib: { level: 6 } }); + output.on('close', () => resolve()); + output.on('error', reject); + archive.on('error', reject); + archive.pipe(output); + archive.file(srcPath, { name: entryName }); + archive.finalize(); + }); +} diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts index 4fe6997..04b89ff 100644 --- a/src/routes/+page.server.ts +++ b/src/routes/+page.server.ts @@ -26,8 +26,7 @@ import { listRestoreTargets, listRestoredRestoreJobLogs, listFailedRestoreJobLogs, - dismissRestoreJobLogs, - undismissRestoreJobLogs + deleteRestoreJobLogs } from '$lib/server/controldesk-pg'; import { listAdditionalEmails, @@ -51,6 +50,12 @@ function parseActivoField(formData: FormData): number { return v === 'true' || v === '1' ? 1 : 0; } +/** Si el cliente presento aviso de cambio de sistema apartado C (T2026-06-111). */ +function parseAnexo24CCambioSistemaField(formData: FormData): boolean { + const v = formData.get('Anexo24CCambioSistema'); + return v === 'true' || v === '1'; +} + /** Lee el id del servidor de restauración elegido (radio); null si no se asignó. */ function parseRestoreTargetId(formData: FormData): number | null { const v = formData.get('restore_target_id'); @@ -127,10 +132,11 @@ function withTimeout(promise: Promise, ms: number, message: string): Promi // SQL sano (<2s), nunca se dispara. Configurable por env. const SQL_LOAD_TIMEOUT_MS = Number(env.PANEL_SQL_LOAD_TIMEOUT_MS) || 12000; -export const load: PageServerLoad = async ({ cookies, url }) => { - // "Mostrar descartados" va por query param y no por estado de cliente: así sobrevive a un - // refresh y a compartir la URL, igual que ?view=. - const includeDismissed = url.searchParams.get('dismissed') === '1'; +export const load: PageServerLoad = async ({ cookies }) => { + // OJO: este load NO debe referenciar `url`. SvelteKit rastrea qué propiedades del evento + // usa, y en cuanto usa `url` cada cambio de `?view=` vuelve a ejecutar TODO este load + // —consultas a SQL Server por nodo, bundles, catálogo y alertas—, con lo que conmutar de + // pestaña deja de ser instantáneo. Ver AppShell.svelte:146. // 1. Auth Check - Verificar token JWT const token = cookies.get('session_token'); @@ -352,14 +358,19 @@ export const load: PageServerLoad = async ({ cookies, url }) => { // filesystem porque las carpetas Procesados/Fallados viven en los servidores de restauración // (rutas Windows remotas) que el host del panel no ve. Si la BD falla, se registra en // `errors.restores` para avisar en la UI en vez de degradar a "sin registros" en silencio. - try { - [restoredBackups, failedRestores] = await Promise.all([ - listRestoredRestoreJobLogs(200, includeDismissed), - listFailedRestoreJobLogs(200, includeDismissed) - ]); - } catch (e: any) { - console.error('Error cargando inventario de restauraciones:', e); - errors.restores = `No se pudo cargar el inventario de restauraciones: ${e?.message ?? e}`; + // Inventario de restauraciones (Respaldos Restaurados / Restores Fallidos): vistas SOLO de + // administrador. No se cargan ni se envían al cliente para usuarios normales (autorización en + // backend, no solo ocultar en la UI — OWASP Broken Access Control). + if (currentUser.es_admin) { + try { + [restoredBackups, failedRestores] = await Promise.all([ + listRestoredRestoreJobLogs(200), + listFailedRestoreJobLogs(200) + ]); + } catch (e: any) { + console.error('Error cargando inventario de restauraciones:', e); + errors.restores = `No se pudo cargar el inventario de restauraciones: ${e?.message ?? e}`; + } } // CALCULAR MÉTRICAS DE RESTAURACIÓN basadas en last_restore_date (ANTES del filtro) @@ -428,7 +439,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => { backupFiles, restoredBackups, failedRestores, - includeDismissed, clientsData, alertsData, basesDeDatosList, @@ -512,6 +522,10 @@ export const actions: Actions = { String(formData.get('ServerName') || '').trim() || DEFAULT_DATABASE_SERVER; const bdName = nodoSubNodo; const activo = parseActivoField(formData); + // Fecha de aviso SAT Anexo 24C (T2026-06-111), opcional: vacio = sin fecha. + const anexo24CAvisoFechaRaw = String(formData.get('Anexo24CAvisoFecha') || '').trim(); + const anexo24CAvisoFecha = anexo24CAvisoFechaRaw || null; + const anexo24CCambioSistema = parseAnexo24CCambioSistemaField(formData); if (!nodoSubNodo || !rfc || !nombre || !correo) { return { success: false, message: 'Faltan campos requeridos' }; @@ -526,7 +540,9 @@ export const actions: Actions = { serverName, bdName, activo, - restoreTargetId + restoreTargetId, + anexo24CAvisoFecha, + anexo24CCambioSistema }); return { success: true }; @@ -553,6 +569,10 @@ export const actions: Actions = { String(formData.get('ServerName') || '').trim() || DEFAULT_DATABASE_SERVER; const bdName = nodoSubNodo; const activo = parseActivoField(formData); + // Fecha de aviso SAT Anexo 24C (T2026-06-111), opcional: vacio = sin fecha. + const anexo24CAvisoFechaRaw = String(formData.get('Anexo24CAvisoFecha') || '').trim(); + const anexo24CAvisoFecha = anexo24CAvisoFechaRaw || null; + const anexo24CCambioSistema = parseAnexo24CCambioSistemaField(formData); if (!id || !nodoSubNodo || !rfc || !nombre || !correo) { return { success: false, message: 'Faltan campos requeridos' }; @@ -567,7 +587,9 @@ export const actions: Actions = { serverName, bdName, activo, - restoreTargetId + restoreTargetId, + anexo24CAvisoFecha, + anexo24CCambioSistema }); return { success: true }; @@ -578,15 +600,16 @@ export const actions: Actions = { }, /** - * Descarta registros de la bitácora: los saca de las vistas sin borrarlos. + * BORRA registros de restores fallidos. Es definitivo: no existe action inversa. * * Recibe los IDs que el operador tenía en pantalla (posiblemente filtrados por el - * buscador), no un filtro, para que lo descartado sea exactamente lo que vio. + * buscador), no un filtro, para que lo borrado sea exactamente lo que vio. * - * Devuelve el conteo REAL de filas afectadas y no la cantidad de IDs recibidos: repetir la - * acción debe reportar 0 en lugar de volver a contar lo ya descartado. + * Devuelve el conteo REAL de filas borradas y no la cantidad de IDs recibidos: si otro + * operador ya los borró, o si la pestaña llevaba rato abierta, el mensaje debe decir la + * verdad en lugar de afirmar que se borró algo que ya no estaba. */ - dismissRestores: async ({ request, cookies }) => { + deleteRestores: async ({ request, cookies }) => { // Guard explícito: este archivo tiene el helper y el comentario de "autorización en // backend, no solo UI", pero varias de sus actions no lo llaman. if (!(await isAdmin(cookies))) { @@ -596,49 +619,37 @@ export const actions: Actions = { const formData = await request.formData(); const ids = parseIdList(formData.get('ids')); if (ids.length === 0) { - return { success: false, message: 'No se recibió ningún registro que descartar.' }; + return { success: false, message: 'No se recibió ningún registro que borrar.' }; } const session = verifyToken(cookies.get('session_token') ?? ''); const user = session ? await getUserById(session.userId) : null; - const affected = await dismissRestoreJobLogs(ids, user?.username ?? null); + const result = await deleteRestoreJobLogs(ids); - return { - success: true, - dismissed: affected, - dismissedIds: ids, - message: - affected === 0 - ? 'No había registros pendientes de descartar.' - : `${affected} registro(s) descartado(s). Siguen en la bitácora del servidor.` - }; - } catch (e: any) { - console.error('Error descartando registros de restauración:', e); - return { success: false, message: e.message }; - } - }, - - /** Revierte un descarte (el botón "Deshacer" del aviso de éxito). */ - undismissRestores: async ({ request, cookies }) => { - if (!(await isAdmin(cookies))) { - return { success: false, message: 'Requiere permisos de administrador.' }; - } - try { - const formData = await request.formData(); - const ids = parseIdList(formData.get('ids')); - if (ids.length === 0) { - return { success: false, message: 'No se recibió ningún registro que restaurar.' }; + // El panel no tiene tabla de auditoría y el borrado es irreversible, así que el log + // del servidor es el único rastro de quién lo hizo. No es un print de depuración. + if (result.deleted > 0) { + console.warn( + `[bitácora] ${user?.username ?? 'desconocido'} borró ${result.deleted} registro(s) de restores fallidos (IDs: ${ids.join(',')})` + ); } - const affected = await undismissRestoreJobLogs(ids); - return { - success: true, - message: - affected === 0 - ? 'Esos registros ya estaban en la lista.' - : `${affected} registro(s) devuelto(s) a la lista.` - }; + + // Los tres casos se distinguen: nada existía, algo no era fallido, o se borró. + let message: string; + if (result.deleted === 0 && result.skippedNotFailed > 0) { + message = 'Solo se pueden borrar restores fallidos; esos registros no lo son.'; + } else if (result.deleted === 0) { + message = 'Esos registros ya no existían. Recarga la vista.'; + } else { + message = `${result.deleted} registro(s) borrado(s) definitivamente.`; + if (result.deleted < result.requested) { + message += ` ${result.requested - result.deleted} ya no existía(n).`; + } + } + + return { success: true, deleted: result.deleted, message }; } catch (e: any) { - console.error('Error restaurando registros descartados:', e); + console.error('Error borrando registros de restauración:', e); return { success: false, message: e.message }; } }, diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index baee60a..1d4dd96 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -6,6 +6,7 @@ import { PANEL_VIEWS, isPanelView, type PanelViewKey } from '$lib/nav'; import { filterRestored, filterFailed } from '$lib/restore-filter'; import AppShell from '$lib/components/AppShell.svelte'; + import Spinner from '$lib/components/Spinner.svelte'; type KitActionPayload = { success?: boolean; message?: string }; @@ -239,70 +240,56 @@ let restoredSearch = $state(''); let failedSearch = $state(''); - // ---- Descartar registros de la bitácora ----------------------------------------- - // Descartar NO borra: saca la fila de estas vistas pero la conserva, así los contadores de - // /servidores-restauracion y la bitácora por servidor no se mueven. - let confirmDismiss = $state<{ ids: number[]; label: string } | null>(null); - let dismissing = $state(false); - /** IDs del último descarte, para ofrecer Deshacer. */ - let lastDismissed = $state([]); - let dismissNotice = $state(null); - let dismissError = $state(null); - - const includeDismissed = $derived(data.includeDismissed ?? false); + // ---- Borrar registros de restores fallidos --------------------------------------- + // BORRA de verdad: la fila deja de existir en restore_job_logs, no hay deshacer, y el + // contador de fallas de los últimos 30 días de /servidores-restauracion baja porque cuenta + // esa misma tabla. El modal de confirmación lo dice explícitamente. + // Solo aplica a fallidos, y el servidor también lo restringe (ver deleteRestoreJobLogs): + // "Respaldos Restaurados" es el registro de auditoría de lo que sí se restauró. + let confirmDelete = $state<{ ids: number[]; label: string } | null>(null); + let deleting = $state(false); + let deleteNotice = $state(null); + let deleteError = $state(null); /** - * Descarta (o deshace) por POST a la action. Se manda la lista de IDs y se confía en el - * conteo que devuelve el servidor, no en la longitud de la lista: si algo ya estaba - * descartado, el mensaje debe decir la verdad. + * Borra por POST a la action. Se confía en el conteo que devuelve el servidor y no en la + * longitud de la lista: si otro operador ya los borró, o la pestaña llevaba rato abierta, + * el mensaje debe decir la verdad. */ - async function submitDismiss(ids: number[], mode: 'dismiss' | 'undismiss' = 'dismiss') { + async function submitDelete(ids: number[]) { if (ids.length === 0) return; - dismissing = true; + deleting = true; try { const body = new FormData(); body.set('ids', ids.join(',')); - const res = await fetch(mode === 'dismiss' ? '?/dismissRestores' : '?/undismissRestores', { - method: 'POST', - body - }); + const res = await fetch('?/deleteRestores', { method: 'POST', body }); const result = parseKitAction(await res.text()); const errMsg = kitActionErrorMessage(res, result); if (errMsg) { - dismissError = errMsg; - dismissNotice = null; + deleteError = errMsg; + deleteNotice = null; return; } const payload = result?.type === 'success' ? (result.data as Record | undefined) : undefined; if (payload?.success === false) { - dismissError = String(payload.message ?? 'No se pudo completar la acción.'); - dismissNotice = null; + deleteError = String(payload.message ?? 'No se pudo completar la acción.'); + deleteNotice = null; return; } - dismissError = null; - // El conteo lo dice el servidor, no la longitud de la lista: si algo ya estaba - // descartado el mensaje debe reflejarlo. - dismissNotice = String(payload?.message ?? 'Listo.'); - lastDismissed = mode === 'dismiss' ? ((payload?.dismissedIds as number[]) ?? []) : []; + deleteError = null; + deleteNotice = String(payload?.message ?? 'Listo.'); await invalidateAll(); } catch (e) { - dismissError = e instanceof Error ? e.message : String(e); - dismissNotice = null; + deleteError = e instanceof Error ? e.message : String(e); + deleteNotice = null; } finally { - dismissing = false; - confirmDismiss = null; + deleting = false; + confirmDelete = null; } } - function toggleDismissed() { - // Va por query param para que sobreviva al refresh, igual que ?view=. - const url = new URL($page.url); - if (includeDismissed) url.searchParams.delete('dismissed'); - else url.searchParams.set('dismissed', '1'); - goto(url, { replaceState: true, keepFocus: true, noScroll: true }); - } let visibleUsuarios = $state(PAGE_SIZE); let showUsuarioModal = $state(false); @@ -575,7 +562,9 @@ ServerName: DEFAULT_DATABASE_SERVER, BDName: '', Activo: true, - RestoreTargetId: null + RestoreTargetId: null, + Anexo24CAvisoFecha: '', + Anexo24CCambioSistema: false }); const openCreateDbModal = () => { @@ -592,7 +581,9 @@ ServerName: DEFAULT_DATABASE_SERVER, BDName: suggestedNodo, Activo: true, - RestoreTargetId: null + RestoreTargetId: null, + Anexo24CAvisoFecha: '', + Anexo24CCambioSistema: false }; showDbCrudModal = true; }; @@ -613,7 +604,9 @@ ServerName: bd.ServerName ?? '', BDName: bd.BDName ?? '', Activo: !!bd.Activo, - RestoreTargetId: bd.RestoreTargetId ?? null + RestoreTargetId: bd.RestoreTargetId ?? null, + Anexo24CAvisoFecha: bd.Anexo24CAvisoFecha ?? '', + Anexo24CCambioSistema: !!bd.Anexo24CCambioSistema }; showDbCrudModal = true; }; @@ -640,6 +633,8 @@ if (formDb.RestoreTargetId != null) { form.append('restore_target_id', String(formDb.RestoreTargetId)); } + form.append('Anexo24CAvisoFecha', formDb.Anexo24CAvisoFecha ?? ''); + form.append('Anexo24CCambioSistema', formDb.Anexo24CCambioSistema ? '1' : '0'); try { const res = await fetch(`?/${action}`, { @@ -804,9 +799,11 @@ const now = new Date(); const csvRows = rows.map((alert: any) => { - const fecha = alert.last_restore_date - ? new Date(alert.last_restore_date).toLocaleString() - : 'Nunca'; + const fecha = alert.not_found + ? 'No encontrada' + : alert.last_restore_date + ? new Date(alert.last_restore_date).toLocaleString() + : 'Nunca'; const dias = alert.last_restore_date ? alert.daysWithout : ''; const cliente = alert.clientData?.Nombre ?? 'N/D'; const correo = alert.clientData?.CorreoNotificacion ?? 'N/D'; @@ -1675,7 +1672,7 @@ {/if} - {#if activeView === 'restored'} + {#if activeView === 'restored' && data.currentUser?.es_admin}

Respaldos restaurados

@@ -1742,7 +1739,7 @@ {#each sliceVisible(restoredRowsLive, visibleRestored) as r (r.id)} - + {r.server_name ?? '—'} {r.node_key ?? r.db_name ?? '—'} @@ -1779,7 +1776,7 @@
{/if} - {#if activeView === 'failed'} + {#if activeView === 'failed' && data.currentUser?.es_admin}

Restores fallidos

@@ -1793,24 +1790,15 @@ {data.errors.restores} {/if} - {#if dismissError} + {#if deleteError}
- {dismissError} + {deleteError}
{/if} - {#if dismissNotice} -
- {dismissNotice} - {#if lastDismissed.length > 0} - - {/if} + {#if deleteNotice} + +
+ {deleteNotice}
{/if}
@@ -1822,31 +1810,22 @@ {/if}
- {#if data.currentUser?.es_admin && failedRowsLive.length > 0} + {/if}
@@ -1892,7 +1871,7 @@ {#each sliceVisible(failedRowsLive, visibleFailed) as f (f.id)} - + {f.server_name ?? '—'} {f.db_name ?? '—'} @@ -1910,29 +1889,18 @@ {/if} {#if data.currentUser?.es_admin} - {#if f.dismissed_at} - - {:else} - - {/if} + {/if} @@ -2231,9 +2199,16 @@ {alert.visible_name} - {alert.last_restore_date - ? new Date(alert.last_restore_date).toLocaleString() - : 'Nunca'} + {#if alert.not_found} + + error_outline + No encontrada + + {:else} + {alert.last_restore_date + ? new Date(alert.last_restore_date).toLocaleString() + : 'Nunca'} + {/if} {alert.clientData?.Nombre ?? 'N/D'} {alert.clientData?.CorreoNotificacion ?? 'N/D'} @@ -2566,44 +2541,55 @@
{/if} -{#if confirmDismiss} - {@const target = confirmDismiss} +{#if confirmDelete} + {@const target = confirmDelete} + + + +
+ + +
+ + +
+ + +

+ Solo aplica si el cliente está desactivado Y marcaste "Presentó aviso de cambio de + sistema" arriba — en ese caso el login de SCAII Web muestra la leyenda del SAT + (con esta fecha si la capturas, sin fecha si la dejas en blanco). Si el checkbox + no está marcado, el cliente inactivo ve el mensaje genérico de siempre. +

+
diff --git a/src/routes/api/dashboard.json/+server.ts b/src/routes/api/dashboard.json/+server.ts index f90aa6d..61b4fa8 100644 --- a/src/routes/api/dashboard.json/+server.ts +++ b/src/routes/api/dashboard.json/+server.ts @@ -1,5 +1,5 @@ import { json } from '@sveltejs/kit'; -import { listDatabaseNodesForMssql } from '$lib/server/controldesk-pg'; +import { listDatabaseNodesForMssql, lookupAlertClientData } from '$lib/server/controldesk-pg'; import { loadSqlDashboardFromNodes, type CatalogNodeRow } from '$lib/server/mssql-nodes'; export const GET = async () => { @@ -7,7 +7,23 @@ export const GET = async () => { const nodes = (await listDatabaseNodesForMssql()) as CatalogNodeRow[]; const bundle = await loadSqlDashboardFromNodes(nodes); const { databaseRows, summaryMain, alertsData } = bundle; - return json({ databaseRows, summaryMain, alertsData }); + + // Enriquecer las alertas con datos de cliente/correo (mismo criterio que la carga inicial + // en +page.server.ts). Sin esto, el auto-refresh reemplazaba las alertas por versiones sin + // clientData y la tabla mostraba Cliente y Correo como "N/D" tras el primer refresco. + const enrichedAlerts = await Promise.all( + alertsData.map(async (alert) => { + let clientData: any = null; + try { + clientData = await lookupAlertClientData(String(alert.visible_name)); + } catch { + /* ignore */ + } + return { ...alert, clientData }; + }) + ); + + return json({ databaseRows, summaryMain, alertsData: enrichedAlerts }); } catch (e: any) { console.error('Error refreshing dashboard data:', e); return json({ error: 'Error refreshing dashboard data' }, { status: 500 }); diff --git a/src/routes/reportes/+page.server.ts b/src/routes/reportes/+page.server.ts index 5af7ca2..e9c5525 100644 --- a/src/routes/reportes/+page.server.ts +++ b/src/routes/reportes/+page.server.ts @@ -13,6 +13,8 @@ export const load: PageServerLoad = async ({ cookies }) => { const currentUser = await getUserById(session.userId); if (!currentUser || !currentUser.activo) throw redirect(303, '/login'); + // Reportes es solo para administradores (autorización en backend, no solo ocultar el enlace). + if (!currentUser.es_admin) throw redirect(303, '/'); // Obtener lista de bases activas para mostrar en UI let bases: { ID: number; Nombre: string; NodoSubNodo: string; BDName: string }[] = []; diff --git a/src/routes/reportes/excel/+server.ts b/src/routes/reportes/excel/+server.ts index 04c4452..dd8d46c 100644 --- a/src/routes/reportes/excel/+server.ts +++ b/src/routes/reportes/excel/+server.ts @@ -1,7 +1,6 @@ import { listDatabaseNodesForMssql } from '$lib/server/controldesk-pg'; import { getMssqlPoolMaster, resolveNodeSqlPassword } from '$lib/server/mssql-nodes'; -import { verifyToken } from '$lib/server/auth'; -import { redirect } from '@sveltejs/kit'; +import { getAdminFromCookies } from '$lib/server/report-excel'; import type { RequestHandler } from './$types'; import ExcelJS from 'exceljs'; @@ -41,11 +40,14 @@ function styleHeader(row: ExcelJS.Row) { } export const GET: RequestHandler = async ({ cookies, url }) => { - // Auth - const token = cookies.get('session_token'); - if (!token) throw redirect(303, '/login'); - const session = verifyToken(token); - if (!session) throw redirect(303, '/login'); + // Reporte solo para administradores (autorización en backend, no solo ocultar el enlace). + const admin = await getAdminFromCookies(cookies); + if (!admin) { + return new Response(JSON.stringify({ error: 'No autorizado: requiere sesión de administrador.' }), { + status: 403, + headers: { 'Content-Type': 'application/json' } + }); + } // Parámetros de filtro opcionales const filterAnio = url.searchParams.get('anio') || ''; diff --git a/src/routes/servidores-restauracion/+page.svelte b/src/routes/servidores-restauracion/+page.svelte index ca82276..481f93d 100644 --- a/src/routes/servidores-restauracion/+page.svelte +++ b/src/routes/servidores-restauracion/+page.svelte @@ -3,6 +3,7 @@ import { enhance, deserialize } from '$app/forms'; import { invalidateAll } from '$app/navigation'; import AppShell from '$lib/components/AppShell.svelte'; + import Spinner from '$lib/components/Spinner.svelte'; import type { CloudRestoreStatus, RestoreTarget, AssignmentNode } from '$lib/server/controldesk-pg'; import { categorize, @@ -888,7 +889,7 @@ class="inline-flex items-center gap-1.5 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 disabled:opacity-60 disabled:cursor-not-allowed" > {#if globalLoading} - progress_activity + {/if} {globalLoading ? 'Calculando…' : 'Recalcular distribución global'} @@ -1029,7 +1030,7 @@ class="inline-flex items-center gap-1.5 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" > {#if globalApplying} - progress_activity + {/if} {globalApplying ? 'Aplicando…' : 'Confirmar y aplicar'} @@ -1314,7 +1315,7 @@ class="inline-flex items-center gap-1.5 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 disabled:opacity-60 disabled:cursor-not-allowed" > {#if sizesLoading} - progress_activity + {/if} {sizesLoading ? 'Cargando tamaños…' : 'Calcular óptimo para este servidor'} @@ -1493,7 +1494,7 @@ @@ -1574,7 +1575,7 @@
{#if logsLoading}

- progress_activity + Cargando bitácora…

{:else if logsError} diff --git a/src/routes/servidores-restauracion/depuracion/+page.server.ts b/src/routes/servidores-restauracion/depuracion/+page.server.ts new file mode 100644 index 0000000..bfac5f3 --- /dev/null +++ b/src/routes/servidores-restauracion/depuracion/+page.server.ts @@ -0,0 +1,116 @@ +/** + * Depuración de bases duplicadas (solo administradores). Tras mover bases a un servidor nuevo, + * las copias siguen en el viejo. Aquí se escanea un servidor viejo (restore_target), se muestra + * qué bases ya están bien en el nuevo y se borran las copias del viejo de forma segura. + */ +import { redirect, fail } from '@sveltejs/kit'; +import { randomUUID } from 'node:crypto'; +import type { PageServerLoad, Actions } from './$types'; +import { verifyToken } from '$lib/server/auth'; +import { getUserById } from '$lib/server/users'; +import { listRestoreTargets } from '$lib/server/controldesk-pg'; +import { scanDuplicates, dropDuplicates } from '$lib/server/dedup-databases'; +import { moveDatabaseToNewServer } from '$lib/server/db-move'; +import { logger } from '$lib/server/logger'; + +async function requireAdmin(cookies: import('@sveltejs/kit').Cookies) { + const token = cookies.get('session_token'); + if (!token) throw redirect(303, '/login'); + + const session = verifyToken(token); + if (!session) throw redirect(303, '/login'); + + const currentUser = await getUserById(session.userId); + if (!currentUser || !currentUser.es_admin) throw redirect(303, '/'); + return currentUser; +} + +export const load: PageServerLoad = async ({ cookies }) => { + const currentUser = await requireAdmin(cookies); + let dbWarning: string | null = null; + + let targets: Awaited> = []; + try { + targets = await listRestoreTargets(); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + logger.error({ message: 'dedup: error listando restore_targets', context: { error: msg } }); + dbWarning = `No se pudo cargar servidores: ${msg}`; + } + + return { targets, currentUser, dbWarning }; +}; + +function parseTargetId(data: FormData): number { + const id = parseInt(data.get('targetId')?.toString() || '0', 10); + return Number.isInteger(id) && id > 0 ? id : 0; +} + +export const actions: Actions = { + /** Escanea un servidor viejo y reconcilia sus bases contra el destino nuevo del catálogo. */ + scan: async ({ request, cookies }) => { + await requireAdmin(cookies); + const traceId = randomUUID(); + const data = await request.formData(); + const targetId = parseTargetId(data); + if (!targetId) return fail(400, { error: 'Selecciona un servidor válido.', traceId }); + + try { + const result = await scanDuplicates(targetId); + return { scan: result }; + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + logger.error({ trace_id: traceId, message: 'dedup: fallo al escanear', context: { targetId, error: msg } }); + return fail(500, { error: `No se pudo escanear el servidor: ${msg}`, traceId }); + } + }, + + /** Borra del servidor viejo las bases seleccionadas (re-verifica seguridad en el servidor). */ + drop: async ({ request, cookies }) => { + const currentUser = await requireAdmin(cookies); + const traceId = randomUUID(); + const data = await request.formData(); + const targetId = parseTargetId(data); + if (!targetId) return fail(400, { error: 'Selecciona un servidor válido.', traceId }); + + let names: string[]; + try { + const raw = data.get('names')?.toString() || '[]'; + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) throw new Error('names no es un arreglo'); + names = parsed.map((n) => String(n).trim()).filter(Boolean); + } catch { + return fail(400, { error: 'Lista de bases inválida.', traceId }); + } + if (names.length === 0) return fail(400, { error: 'No hay bases seleccionadas.', traceId }); + + try { + const outcomes = await dropDuplicates(targetId, names, currentUser.username); + return { drop: { targetId, outcomes } }; + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + logger.error({ trace_id: traceId, message: 'dedup: fallo al borrar', context: { targetId, error: msg } }); + return fail(500, { error: `No se pudieron borrar las bases: ${msg}`, traceId }); + } + }, + + /** Manda una base que solo está en el viejo hacia su servidor nuevo (vía CRA) y, al confirmar, la borra del viejo. */ + move: async ({ request, cookies }) => { + const currentUser = await requireAdmin(cookies); + const traceId = randomUUID(); + const data = await request.formData(); + const targetId = parseTargetId(data); + const name = data.get('name')?.toString().trim(); + if (!targetId) return fail(400, { error: 'Selecciona un servidor válido.', traceId }); + if (!name) return fail(400, { error: 'Base requerida.', traceId }); + + try { + const result = await moveDatabaseToNewServer(targetId, name, currentUser.username); + return { move: result }; + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + logger.error({ trace_id: traceId, message: 'dedup: fallo al mover', context: { targetId, name, error: msg } }); + return fail(500, { error: `No se pudo mandar la base al nuevo: ${msg}`, traceId }); + } + } +}; diff --git a/src/routes/servidores-restauracion/depuracion/+page.svelte b/src/routes/servidores-restauracion/depuracion/+page.svelte new file mode 100644 index 0000000..18c059a --- /dev/null +++ b/src/routes/servidores-restauracion/depuracion/+page.svelte @@ -0,0 +1,407 @@ + + + +
+ + +
+

Depuración de bases duplicadas

+

+ Al mover bases a un servidor nuevo, las copias quedaron también en el viejo. Elige el + servidor viejo, revisa qué bases ya están bien en su servidor nuevo y borra las copias + del viejo. +

+
+ +
+ warning + + El borrado hace DROP DATABASE en el servidor viejo y cierra las + conexiones activas (SINGLE_USER). Es irreversible. Solo se habilita el borrado de bases + que ya existen en el nuevo con un tamaño coherente. + +
+ + {#if data.dbWarning} +
+ {data.dbWarning} +
+ {/if} + {#if errorMsg} +
+ {errorMsg} +
+ {/if} + + +
+
+ + +
+
+ + + {#if outcomes.length > 0} +
+

Resultado del borrado

+
    + {#each outcomes as o (o.name)} +
  • + + {o.ok ? 'check_circle' : 'error'} + + {o.name} + + {o.ok ? 'borrada del servidor viejo' : o.message || o.status} + +
  • + {/each} +
+
+ {/if} + + {#if scannedTarget} +
+

+ Servidor viejo: {scannedTarget.name} + ({scannedTarget.server_ip}) + · {rows.length} base{rows.length === 1 ? '' : 's'} · {deletableRows.length} segura{deletableRows.length === + 1 + ? '' + : 's'} +

+
+ + +
+
+ + {#if rows.length === 0} +

+ No hay bases de usuario en este servidor. +

+ {:else} +
+ + + + + + + + + + + + + + + + {#each rows as row (row.name)} + {@const meta = STATUS_META[row.status]} + + + + + + + + + + + + {/each} + +
BaseViejo: tamañoViejo: últ. restoreServidor nuevoNuevo: tamañoNuevo: últ. restoreEstadoAcciones
+ toggle(row.name)} + title={row.deletable + ? 'Marcar para borrar del servidor viejo' + : 'Solo se pueden borrar bases seguras'} + class="h-4 w-4 rounded border-slate-300 disabled:opacity-40" + /> + {row.name}{formatSize(row.oldSizeMb)}{formatDate(row.oldLastRestore)} + {#if row.newServer} + {row.newServer} + {#if row.newServerLabel} + {row.newServerLabel} + {/if} + {:else} + — + {/if} + {formatSize(row.newSizeMb)}{formatDate(row.newLastRestore)} + + {meta.icon} + {meta.label} + + + {#if row.movable} +
+ + + +
+ {/if} + {#if moveNotes[row.name]} +

{moveNotes[row.name].message}

+ {/if} +
+
+ {/if} + {/if} +
+ + + {#if confirmOpen} +
+
+
+ delete_forever +

Confirmar borrado

+
+

+ Se hará DROP DATABASE en + {scannedTarget?.name} + ({scannedTarget?.server_ip}) + de estas {selected.size} base{selected.size === 1 ? '' : 's'}. Esta acción es + irreversible. +

+
    + {#each selectedNames as name (name)} +
  • {name}
  • + {/each} +
+
+ +
+ + + +
+
+
+
+ {/if} +
diff --git a/src/routes/versiones-cras/+page.server.ts b/src/routes/versiones-cras/+page.server.ts index 5694313..731ead2 100644 --- a/src/routes/versiones-cras/+page.server.ts +++ b/src/routes/versiones-cras/+page.server.ts @@ -29,9 +29,12 @@ import { ensureCached, isCached, pruneCache, + prunePlan, removeCached, - ArtifactError + ArtifactError, + type PrunePlan } from '$lib/server/cras-artifacts'; +import { installPlatformVerdict, platformLabel } from '$lib/cras-version'; import { syncCrasReleasesFromGitea } from '$lib/server/cras-sync'; import { GiteaError, isGiteaConfigured, packageLocation } from '$lib/server/gitea-packages'; import { installCrasOnTarget, InstallError } from '$lib/server/cras-install'; @@ -122,6 +125,16 @@ export const load: PageServerLoad = async ({ cookies }) => { console.error('Error midiendo la caché de artefactos:', e); } + // Qué se borraría al podar, con el mismo criterio con que se ejecuta: el control de + // "Liberar" tiene que decir cuánto libera antes de que el operador confirme, y quedar + // apagado cuando no hay nada que liberar (con ≤3 versiones en caché no borra nada). + let prune: PrunePlan = { versions: [], bytes: 0 }; + try { + prune = await prunePlan(releases.filter((r) => r.is_active).map((r) => r.version)); + } catch (e) { + console.error('Error calculando el plan de poda de la caché:', e); + } + // Avisos de configuración: sin estos datos la pantalla se ve bien pero no puede operar. const configWarnings: string[] = []; if (!isGiteaConfigured()) { @@ -147,6 +160,7 @@ export const load: PageServerLoad = async ({ cookies }) => { runs, cached, usage, + prune, dbWarning, configWarnings, giteaConfigured: isGiteaConfigured(), @@ -330,6 +344,52 @@ export const actions: Actions = { const mode = modeRaw === 'update' ? 'update' : 'install'; const autostart = autostartRaw === 'desktop' ? 'desktop' : autostartRaw === 'none' ? 'none' : 'service'; + const platformAck = data.get('platform_ack')?.toString() ?? ''; + + // Red de seguridad del formulario. Se valida ANTES de abrir el run porque la única guarda + // que había —assertSystemMatches, dentro del instalador— ya corre con la sesión SSH + // abierta: el operador se llevaba un run fallido en la bitácora en lugar de un aviso. + const release = await getCrasReleaseById(releaseId); + if (!release) { + return fail(404, { error: 'La versión indicada no existe en el catálogo.' }); + } + + let target: Awaited>[number] | undefined; + try { + target = (await listCrasTargetInventory()).find( + (t) => t.restore_target_id === targetId + ); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + return fail(500, { error: `No se pudo validar el servidor destino: ${msg}` }); + } + if (!target) { + return fail(404, { error: 'El servidor de restauración ya no existe.' }); + } + if (target.running_install_id) { + return fail(409, { + error: + `Ya hay una instalación en curso en ${target.name} ` + + `(run #${target.running_install_id}). Espera a que termine antes de lanzar otra.` + }); + } + + const verdict = installPlatformVerdict(target.platform, release.platform, platformAck); + if (verdict === 'mismatch') { + return fail(409, { + error: + `${target.name} es ${platformLabel(target.platform)} y ${release.version} es ` + + `${platformLabel(release.platform)}. Elige el artefacto de la plataforma correcta.` + }); + } + if (verdict === 'needs-ack') { + return fail(422, { + error: + `No se pudo determinar la plataforma de ${target.name}. Confirma en el ` + + 'formulario que el artefacto corresponde al sistema del servidor, o captura ' + + 'el campo SO en Servidores de Restauración.' + }); + } const apiUrl = panelApiUrl(); const apiToken = panelApiToken(); diff --git a/src/routes/versiones-cras/+page.svelte b/src/routes/versiones-cras/+page.svelte index 90dace9..e11e815 100644 --- a/src/routes/versiones-cras/+page.svelte +++ b/src/routes/versiones-cras/+page.svelte @@ -2,8 +2,20 @@ import { onDestroy } from 'svelte'; import { enhance } from '$app/forms'; import { invalidateAll } from '$app/navigation'; + import type { SubmitFunction } from '@sveltejs/kit'; import AppShell from '$lib/components/AppShell.svelte'; + import Spinner from '$lib/components/Spinner.svelte'; import { platformLabel } from '$lib/cras-version'; + import { + INSTALL_STEP_LABEL, + currentPhaseLabel, + expectedStepCount, + formatElapsed, + progressPercent, + type AutostartMode, + type InstallStepView, + type RunStatus + } from '$lib/cras-install-progress'; import type { CrasRelease, CrasTargetInventory, CrasInstallRun } from '$lib/server/cras-releases'; let { data, form } = $props(); @@ -40,17 +52,36 @@ // ---- Modal de instalación --------------------------------------------------- let installTarget = $state(null); let installReleaseId = $state(null); - let installAutostart = $state<'service' | 'desktop' | 'none'>('service'); + let installAutostart = $state('service'); let installing = $state(false); + /** Confirmación explícita de plataforma cuando el panel no la pudo determinar. */ + let platformAck = $state(false); + + let installOptions = $derived( + installTarget ? releasesFor(installTarget.platform) : [] + ); + let installRelease = $derived( + installOptions.find((r) => r.id === installReleaseId) ?? null + ); + let needsPlatformAck = $derived(Boolean(installTarget && !installTarget.platform)); + let installBlockedReason = $derived( + !installReleaseId + ? 'Elige la versión a instalar.' + : needsPlatformAck && !platformAck + ? 'Confirma la plataforma del servidor.' + : null + ); - /** Versión propuesta al abrir: la activa de la plataforma del servidor. */ function openInstall(target: CrasTargetInventory) { installTarget = target; installAutostart = 'service'; - installReleaseId = - target.active_release_id ?? (releasesFor(target.platform)[0]?.id ?? null); - progressSteps = []; - progressStatus = null; + platformAck = false; + // Se preselecciona SOLO si el panel sabe la plataforma del servidor. Sin plataforma no se + // adivina: `releasesFor(null)` es TODO el catálogo, ordenado por descubrimiento, así que + // el fallback anterior proponía el artefacto más reciente de cualquier plataforma — un + // clic y el .tar.gz de Linux salía hacia un Windows. + installReleaseId = target.platform ? target.active_release_id : null; + resetProgress(); } function closeInstall() { @@ -62,16 +93,25 @@ // ---- Progreso por polling ---------------------------------------------------- // La acción `install` es bloqueante (sube ~270 MB), así que el avance se lee de // cras_install_runs por un endpoint aparte en lugar de esperar la respuesta a ciegas. - interface Step { - step: string; - detail?: string; - at: string; - ok: boolean; - } - let progressSteps = $state([]); - let progressStatus = $state(null); + let progressSteps = $state([]); + let progressStatus = $state(null); + /** Run que se está siguiendo. Ancla el polling para no adoptar el de otra instalación. */ + let progressRunId = $state(null); + let progressError = $state(null); + let pollFailures = $state(0); + let installStartedAt = $state(null); + let phaseStartedAt = $state(0); + let nowTick = $state(0); let pollTimer: ReturnType | null = null; + function resetProgress() { + progressSteps = []; + progressStatus = null; + progressRunId = null; + progressError = null; + pollFailures = 0; + } + function stopPolling() { if (pollTimer) { clearInterval(pollTimer); @@ -80,19 +120,47 @@ } /** - * Lee el progreso. Durante la instalación se consulta por servidor, porque la acción es - * bloqueante y el runId solo se conoce cuando ya terminó. + * Lee el progreso del run EN CURSO. Durante la instalación se consulta por servidor, porque + * la acción es bloqueante y el runId solo se conoce cuando ya terminó. + * + * `adopt` distingue las dos fases. Mientras la instalación corre solo se adopta un run en + * estado 'running': la action valida versión, credenciales, plataforma y ruta ANTES de abrir + * el run, así que las primeras lecturas por targetId devuelven todavía el run ANTERIOR de ese + * servidor (`ORDER BY started_at DESC LIMIT 1`). Adoptarlo pintaba sus pasos como si fueran + * los de ahora y, peor, cortaba el polling para el resto de la instalación. */ - async function pollProgress(query: string) { + async function pollProgress(query: string, adopt: 'solo-en-curso' | 'cualquiera' = 'solo-en-curso') { try { const res = await fetch(`/versiones-cras/install-runs?${query}`); - if (!res.ok) return; + if (!res.ok) { + // 404 = el run todavía no existe, es lo esperado al arrancar. Cualquier otro + // código sí se cuenta: una sesión vencida a media instalación congelaría el + // progreso sin explicación. + if (res.status !== 404) pollFailures += 1; + return; + } + pollFailures = 0; const body = await res.json(); + // El estado local no se degrada a null: mientras la acción bloquee, 'running' es la + // verdad y solo el backend puede moverlo a completed/failed. + const status: RunStatus = + body.status === 'completed' || body.status === 'failed' ? body.status : 'running'; + + if (progressRunId === null) { + if (status !== 'running' && adopt === 'solo-en-curso') return; + progressRunId = typeof body.id === 'number' ? body.id : null; + } else if (typeof body.id === 'number' && body.id !== progressRunId) { + return; // otro run del mismo servidor: no es el que se está siguiendo + } + progressSteps = body.steps ?? []; - progressStatus = body.status ?? null; - if (body.status && body.status !== 'running') stopPolling(); + progressError = body.error_message ?? null; + progressStatus = status; + if (status !== 'running') stopPolling(); } catch { - // Un fallo de polling no cambia el resultado de la instalación; se reintenta. + // Un fallo de red no cambia el resultado de la instalación; se reintenta al siguiente + // tick y se avisa al operador si son varios seguidos. + pollFailures += 1; } } @@ -108,6 +176,28 @@ pollTimer = setInterval(() => void pollProgress(query), 2000); } + // Cronómetro: una instalación tarda minutos y sin él no hay forma de distinguir "avanzando + // despacio" de "colgada". + $effect(() => { + if (!installing) return; + const timer = setInterval(() => (nowTick = Date.now()), 1000); + return () => clearInterval(timer); + }); + + // Reinicia el cronómetro de fase cuando llega un paso nuevo. Se usa el reloj del NAVEGADOR y + // no el sello `at` del paso, que viene del reloj del panel y puede estar desfasado. + $effect(() => { + progressSteps.length; + phaseStartedAt = Date.now(); + }); + + let elapsedLabel = $derived(formatElapsed(nowTick - (installStartedAt ?? nowTick))); + let phaseElapsedLabel = $derived(formatElapsed(nowTick - (phaseStartedAt || nowTick))); + let expectedSteps = $derived(expectedStepCount(installAutostart)); + let progressPct = $derived( + progressPercent(progressSteps.length, installAutostart, progressStatus ?? 'running') + ); + // ---- Verificación en vivo ---------------------------------------------------- // Sonda bajo demanda contra el servidor. Responde "¿está disponible AHORA?", a diferencia // del último reporte del agente, que solo dice cuándo arrancó. @@ -137,13 +227,18 @@ let verifying = $state(null); let verifyResult = $state(null); let verifyError = $state(null); - let copied = $state(false); + /** + * Qué se acaba de copiar. Es una clave y no un booleano porque hay más de un botón de copiar + * en la pantalla: con una bandera global, copiar el comando de remediación ponía "Copiado" en + * el sha256 del catálogo también. + */ + let copiedKey = $state(null); async function verifyTarget(targetId: number) { verifying = targetId; verifyResult = null; verifyError = null; - copied = false; + copiedKey = null; try { const res = await fetch(`/versiones-cras/verify?targetId=${targetId}`); const body = await res.json(); @@ -159,13 +254,15 @@ } } - async function copyCommand(command: string) { + async function copyValue(key: string, value: string) { try { - await navigator.clipboard.writeText(command); - copied = true; - setTimeout(() => (copied = false), 2000); + await navigator.clipboard.writeText(value); + copiedKey = key; + setTimeout(() => { + if (copiedKey === key) copiedKey = null; + }, 2000); } catch { - // Sin permiso de portapapeles (o sin HTTPS): el comando está visible para copiarlo + // Sin permiso de portapapeles (o sin HTTPS): el valor está visible para copiarlo // a mano, así que no hace falta avisar nada. } } @@ -184,15 +281,95 @@ return 'fail'; } - // ---- Confirmación de borrado ------------------------------------------------- + // ---- Confirmaciones ---------------------------------------------------------- let confirmDelete = $state(null); + let confirmPrune = $state(false); + // ---- Acciones y refresco ----------------------------------------------------- let busyAction = $state(null); + /** `invalidateAll()` no dispara `$navigating`, así que la barra del AppShell no cubre esto. */ + let refreshing = $state(false); + + /** + * Handler común de las acciones de la pantalla: marca ocupado, refresca los datos y libera. + * Sustituye seis copias del mismo closure de `use:enhance`. + */ + function submitAction(key: string, onDone?: () => void): SubmitFunction { + return () => { + busyAction = key; + return async ({ update }) => { + await update({ reset: false }); + refreshing = true; + await invalidateAll(); + refreshing = false; + busyAction = null; + onDone?.(); + }; + }; + } + + /** + * `target.running_install_id` viene del load y no se refresca hasta el invalidateAll final, + * así que la fila seguía ofreciendo Verificar/Instalar durante toda la instalación y se podía + * lanzar una segunda desde otra pestaña. Se marca localmente en cuanto se envía el formulario. + */ + let localInstallingTargetId = $state(null); + + function isInstalling(target: CrasTargetInventory): boolean { + return ( + target.running_install_id !== null || + localInstallingTargetId === target.restore_target_id + ); + } + + // ---- Avisos ------------------------------------------------------------------ + // Se compara por identidad del objeto `form`, que es nuevo en cada resultado de action. + let dismissedForm = $state(null); + let showFormBanner = $derived(Boolean(form) && form !== dismissedForm); + + // Los avisos de éxito se descartan solos; los errores se quedan hasta que el operador los + // cierre — desaparecer un error es peor que estorbar. + $effect(() => { + const current = form; + if (!current || !('success' in current)) return; + const timer = setTimeout(() => (dismissedForm = current), 8000); + return () => clearTimeout(timer); + }); + + let anyModalOpen = $derived( + Boolean(installTarget || confirmDelete || confirmPrune || verifyResult || verifyError) + ); + + /** Cierra el modal de más arriba en la pila. Ninguno tenía cierre con Escape. */ + function closeTopModal() { + if (verifyResult || verifyError) { + verifyResult = null; + verifyError = null; + return; + } + if (confirmDelete) { + confirmDelete = null; + return; + } + if (confirmPrune) { + confirmPrune = false; + return; + } + if (installTarget) closeInstall(); + } + { + if (e.key === 'Escape' && anyModalOpen) closeTopModal(); + }} +/> + -
- + +
+ {#if data.dbWarning}
error_outline @@ -200,44 +377,71 @@
{/if} - {#each data.configWarnings ?? [] as warning} -
- warning_amber - {warning} -
- {/each} - - {#if form?.error} -
- error_outline - {form.error} -
- {/if} - {#if form?.success} -
- check_circle - {form.success} -
+ + {#if (data.configWarnings ?? []).length} +
+ + warning_amber + {data.configWarnings.length} aviso(s) de configuración del panel + +
    + {#each data.configWarnings as warning}
  • {warning}
  • {/each} +
+
{/if} - - {#if form?.skipped?.length} -
-

Archivos omitidos en la sincronización:

-
    - {#each form.skipped as item} -
  • {item.file_name} — {item.reason}
  • - {/each} -
-
- {/if} - {#if form?.warnings?.length} -
-
    - {#each form.warnings as warning}
  • {warning}
  • {/each} -
-
- {/if} + +
+ {#if showFormBanner && form?.error} +
+ error_outline +

{form.error}

+ +
+ {/if} + {#if showFormBanner && form?.success} +
+ check_circle +

{form.success}

+ +
+ {/if} + + + {#if showFormBanner && form?.skipped?.length} +
+

Archivos omitidos en la sincronización:

+
    + {#each form.skipped as item} +
  • {item.file_name} — {item.reason}
  • + {/each} +
+
+ {/if} + {#if showFormBanner && form?.warnings?.length} +
+
    + {#each form.warnings as warning}
  • {warning}
  • {/each} +
+
+ {/if} +
@@ -254,28 +458,17 @@ {data.packageLocation}

-
{ - busyAction = 'sync'; - return async ({ update }) => { - await update(); - busyAction = null; - }; - }} - > +
@@ -291,32 +484,37 @@ Caché local: {formatBytes(data.usage?.total_bytes)} en {data.usage?.versions?.length ?? 0} versión(es) - {#if (data.usage?.versions?.length ?? 0) > 0} -
{ - busyAction = 'prune'; - return async ({ update }) => { - await update(); - busyAction = null; - }; - }} + + {#if data.prune?.versions?.length} + -
+ cleaning_services + Liberar {data.prune.versions.length} versión(es) · {formatBytes(data.prune.bytes)} + + {:else if (data.usage?.versions?.length ?? 0) > 0} + + Nada que liberar + {/if}
-
+
+ +

+ La versión activa es por plataforma: pueden estar activas al mismo + tiempo la de Linux y la de Windows. Es la que se propone al instalar en cada servidor. +

+
@@ -343,9 +541,17 @@
{formatBytes(release.file_size)} {#if release.sha256} - + {:else} sin hash @@ -356,8 +562,11 @@
{#if release.is_active} - - Activa + + Activa en {platformLabel(release.platform)} {/if} {#if cached[release.id]} @@ -374,11 +583,15 @@
{#if !release.is_active} -
+
@@ -400,12 +618,17 @@ {/if} {#if cached[release.id]} - + @@ -414,13 +637,7 @@ { - busyAction = `precache-${release.id}`; - return async ({ update }) => { - await update(); - busyAction = null; - }; - }} + use:enhance={submitAction(`precache-${release.id}`)} >
+

Servidores de restauración

-
+
- + + @@ -525,22 +754,23 @@
Servidor Plataforma InstaladaActiva + Le toca + Último reporte Acciones
{formatDate(target.reported_at)} - {#if target.running_install_id} + {#if isInstalling(target)} - progress_activity + instalación en curso {:else} {/if} @@ -722,19 +963,22 @@ Sonda de solo lectura. No instala ni reinicia nada en el servidor.

{/if} + -
+
+
+
{/if} @@ -748,67 +992,143 @@ if (e.target === e.currentTarget) closeInstall(); }} > -
-

- {target.installed_version ? 'Actualizar' : 'Instalar'} CloudRestoreAS en {target.name} -

-

- El panel sube el artefacto por SFTP, verifica su sha256 en el destino, corre el - instalador del paquete y siembra config/.env con la - URL, el token y la instancia. El servidor no descarga nada de internet. -

+ + @@ -913,38 +1307,44 @@ if (e.target === e.currentTarget) confirmDelete = null; }} > -
-

Quitar del catálogo

-

- Se quitará {release.version} - ({platformLabel(release.platform)}/{release.arch}) del catálogo del panel. -

-

- El paquete sigue publicado en Gitea y una nueva sincronización lo volverá a - registrar. Esto no desinstala nada de ningún servidor. -

+
{/if} + + +{#if confirmPrune} + +{/if}