From 1543368636dc0cb7398644b86ca50c75d21fcd4d Mon Sep 17 00:00:00 2001 From: hreyes Date: Mon, 10 Aug 2026 09:58:06 -0600 Subject: [PATCH] fix: dashboard con puloling saturado --- .env.example | 26 + src/lib/server/controldesk-pg.ts | 251 ++++++ src/lib/server/cras-monitor-config.ts | 171 ++++ src/lib/server/cras-monitor-core.ts | 322 +++++++ src/lib/server/cras-monitor-email.ts | 239 ++++++ src/lib/server/cras-monitor-state.ts | 278 ++++++ src/lib/server/cras-monitor.ts | 572 +++++++++++++ src/lib/server/cras-releases.ts | 3 +- src/lib/server/db-move.ts | 20 +- src/lib/server/dedup-databases.ts | 20 +- src/lib/server/email-service.ts | 31 +- src/lib/server/mssql-breaker.test.ts | 234 +++++ src/lib/server/mssql-breaker.ts | 224 +++++ src/lib/server/mssql-nodes.test.ts | 126 ++- src/lib/server/mssql-nodes.ts | 799 +++++++++++++++--- src/routes/+page.server.ts | 97 ++- src/routes/+page.svelte | 177 +++- src/routes/api/dashboard.json/+server.ts | 135 ++- .../servidores-restauracion/+page.svelte | 19 +- .../node-sizes/+server.ts | 25 +- 20 files changed, 3587 insertions(+), 182 deletions(-) create mode 100644 src/lib/server/cras-monitor-config.ts create mode 100644 src/lib/server/cras-monitor-core.ts create mode 100644 src/lib/server/cras-monitor-email.ts create mode 100644 src/lib/server/cras-monitor-state.ts create mode 100644 src/lib/server/cras-monitor.ts create mode 100644 src/lib/server/mssql-breaker.test.ts create mode 100644 src/lib/server/mssql-breaker.ts diff --git a/.env.example b/.env.example index b34ea3c..847d5cf 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,32 @@ PANEL_MSSQL_USER=sa PANEL_MSSQL_PASSWORD=Clave.2025 # En contenedor Docker: reescribe localhost en server_name → host.docker.internal # PANEL_MSSQL_DOCKER=true + +# --- Sondeo del dashboard a SQL Server ------------------------------------------------------- +# Acotan el trabajo del barrido de métricas. Nacen de un incidente: el barrido iba por BASE en vez de +# por servidor y sin memoria de fallos, así que un servidor cuyo firewall tiraba los paquetes recibía +# un intento por base, por barrido y por pestaña abierta —le inundó el puerto 1433—, y de paso la +# carga se pasaba del presupuesto y el dashboard aparecía en ceros. +# OJO: en Docker el .env se congela al CREAR el contenedor; hay que recrearlo, no reiniciarlo. +# +# Timeout de conexión. Un SQL Server sano conecta en <1 s. Default 5000, rango 500..60000. +# PANEL_MSSQL_CONNECT_TIMEOUT_MS=5000 +# Cuánto se recuerda que un servidor no contestó, para no reintentar por cada base que tenga. +# Default 60000 (1 min), rango 5000..3600000. +# PANEL_MSSQL_DOWN_COOLDOWN_MS=60000 +# Tope de consulta del dashboard. Debe quedar POR DEBAJO de PANEL_SQL_LOAD_TIMEOUT_MS: el default de +# node-mssql (15 s) es mayor que el presupuesto de la página (12 s) y una sola consulta lenta la +# reventaba. Default 8000, rango 1000..60000. +# PANEL_MSSQL_DASHBOARD_REQUEST_TIMEOUT_MS=8000 +# Cuánto vale un barrido antes de repetirlo. Se comparte entre pestañas y endpoints, así N pestañas +# cuestan un sondeo y no N. Conviene menor que el auto-refresco de 40 s. Default 30000, rango 0..600000. +# PANEL_DASHBOARD_CACHE_TTL_MS=30000 +# Máximo de pools en caché. Que sea mayor que el número de servidores distintos del catálogo, o se +# cierran y reabren pools vivos en cada barrido. Default 32, rango 4..256. +# PANEL_MSSQL_MAX_POOLS=32 +# Presupuesto de la carga de métricas en la página. Default 12000. +# PANEL_SQL_LOAD_TIMEOUT_MS=12000 + # 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 diff --git a/src/lib/server/controldesk-pg.ts b/src/lib/server/controldesk-pg.ts index a447a13..e19c3f8 100644 --- a/src/lib/server/controldesk-pg.ts +++ b/src/lib/server/controldesk-pg.ts @@ -43,6 +43,11 @@ function qNodeLastRestore(): string { return `"${s.replace(/"/g, '""')}"."node_last_restore"`; } +function qNodeMetricsCache(): string { + const s = schemaName(); + return `"${s.replace(/"/g, '""')}"."node_metrics_cache"`; +} + function isPgUndefinedTable(err: unknown): boolean { return typeof err === 'object' && err !== null && (err as { code?: string }).code === '42P01'; } @@ -1250,6 +1255,252 @@ export async function listRecentRestoreJobLogs( } } +// ============================================================================ +// Caché durable de métricas por nodo (node_metrics_cache). +// ============================================================================ + +/** + * Último valor conocido de las métricas de cada base, para que el dashboard NO dependa de que el + * sondeo a SQL Server termine. + * + * Nace de un incidente: las métricas eran 100% en vivo, así que al reiniciar el servicio (que vacía + * la caché de pools del proceso) y con un servidor cuyo firewall tira los paquetes, el dashboard se + * quedó en 0 bases y 0 GB, sin nada a qué caer y sin distinguirse de un cero legítimo. + * + * Llaveada por NODO y no por la asignación actual —mismo criterio que `node_last_restore`— para que + * el histórico sobreviva a reasignar la base a otro restaurador. + */ +async function ensureNodeMetricsCacheTable(): Promise { + await pgPool.query('CREATE SCHEMA IF NOT EXISTS a24c'); + await pgPool.query(` + CREATE TABLE IF NOT EXISTS ${qNodeMetricsCache()} ( + database_node_id INTEGER PRIMARY KEY, + server_name VARCHAR(255), + db_name VARCHAR(255), + visible_name VARCHAR(255), + size_mb NUMERIC(18,2), + total_size_gb NUMERIC(18,2), + last_restore_date TIMESTAMPTZ, + state_desc VARCHAR(60), + recovery_model_desc VARCHAR(60), + create_date TIMESTAMPTZ, + restore_history JSONB, + effectiveness JSONB, + measured_at TIMESTAMPTZ, + last_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_error TEXT, + missing_since TIMESTAMPTZ, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + `); +} + +export interface NodeMetricsCacheRow { + database_node_id: number; + server_name: string | null; + db_name: string | null; + visible_name: string | null; + size_mb: number | null; + total_size_gb: number | null; + last_restore_date: Date | null; + state_desc: string | null; + recovery_model_desc: string | null; + create_date: Date | null; + restore_history: { restore_date: string }[] | null; + effectiveness: { month: string; effectiveness: number }[] | null; + /** Cuándo se midió de verdad contra SQL Server. `null` = nunca se logró medir. */ + measured_at: Date | null; + /** Cuándo se intentó por última vez, con éxito o sin él. */ + last_attempt_at: Date; + last_error: string | null; + /** Desde cuándo se sabe que la base ya NO existe en su servidor. */ + missing_since: Date | null; +} + +/** Métricas cacheadas de todas las bases. Numéricos convertidos: pg devuelve NUMERIC como texto. */ +export async function listNodeMetricsCache(): Promise { + try { + await ensureNodeMetricsCacheTable(); + const r = await pgPool.query(`SELECT * FROM ${qNodeMetricsCache()}`); + return (r.rows as Record[]).map((row) => ({ + ...row, + size_mb: row.size_mb === null ? null : Number(row.size_mb), + total_size_gb: row.total_size_gb === null ? null : Number(row.total_size_gb) + })) as NodeMetricsCacheRow[]; + } catch (e) { + if (isPgUndefinedTable(e)) return []; + throw e; + } +} + +export interface NodeMetricsMeasurement { + databaseNodeId: number; + serverName: string | null; + dbName: string | null; + visibleName: string | null; + sizeMb: number | null; + totalSizeGb: number | null; + lastRestoreDate: Date | string | null; + stateDesc: string | null; + recoveryModelDesc: string | null; + createDate: Date | string | null; + restoreHistory: { restore_date: string }[]; + effectiveness: { month: string; effectiveness: number }[]; +} + +const toIsoOrNull = (v: Date | string | null | undefined): string | null => { + if (!v) return null; + const d = v instanceof Date ? v : new Date(v); + return Number.isFinite(d.getTime()) ? d.toISOString() : null; +}; + +/** + * Guarda las mediciones que SÍ se lograron, en un solo viaje a PostgreSQL. + * + * Se manda como un `jsonb` y se expande con `jsonb_to_recordset` en vez de armar N tuplas de + * parámetros: el número de bases es variable (decenas) y así la consulta es una sola, siempre la + * misma, sin explosión de parámetros. + * + * Medir con éxito limpia `last_error` y `missing_since`: la base existe y se pudo leer. + */ +export async function upsertNodeMetrics(rows: NodeMetricsMeasurement[]): Promise { + if (rows.length === 0) return; + await ensureNodeMetricsCacheTable(); + const payload = rows.map((r) => ({ + database_node_id: r.databaseNodeId, + server_name: r.serverName, + db_name: r.dbName, + visible_name: r.visibleName, + size_mb: r.sizeMb, + total_size_gb: r.totalSizeGb, + last_restore_date: toIsoOrNull(r.lastRestoreDate), + state_desc: r.stateDesc, + recovery_model_desc: r.recoveryModelDesc, + create_date: toIsoOrNull(r.createDate), + restore_history: r.restoreHistory ?? [], + effectiveness: r.effectiveness ?? [] + })); + await pgPool.query( + ` + INSERT INTO ${qNodeMetricsCache()} ( + database_node_id, server_name, db_name, visible_name, size_mb, total_size_gb, + last_restore_date, state_desc, recovery_model_desc, create_date, + restore_history, effectiveness, + measured_at, last_attempt_at, last_error, missing_since, updated_at + ) + SELECT + t.database_node_id, t.server_name, t.db_name, t.visible_name, t.size_mb, t.total_size_gb, + t.last_restore_date, t.state_desc, t.recovery_model_desc, t.create_date, + t.restore_history, t.effectiveness, + now(), now(), NULL, NULL, now() + FROM jsonb_to_recordset($1::jsonb) AS t( + database_node_id INTEGER, + server_name VARCHAR(255), + db_name VARCHAR(255), + visible_name VARCHAR(255), + size_mb NUMERIC(18,2), + total_size_gb NUMERIC(18,2), + last_restore_date TIMESTAMPTZ, + state_desc VARCHAR(60), + recovery_model_desc VARCHAR(60), + create_date TIMESTAMPTZ, + restore_history JSONB, + effectiveness JSONB + ) + ON CONFLICT (database_node_id) DO UPDATE SET + server_name = EXCLUDED.server_name, + db_name = EXCLUDED.db_name, + visible_name = EXCLUDED.visible_name, + size_mb = EXCLUDED.size_mb, + total_size_gb = EXCLUDED.total_size_gb, + last_restore_date = EXCLUDED.last_restore_date, + state_desc = EXCLUDED.state_desc, + recovery_model_desc = EXCLUDED.recovery_model_desc, + create_date = EXCLUDED.create_date, + restore_history = EXCLUDED.restore_history, + effectiveness = EXCLUDED.effectiveness, + measured_at = now(), + last_attempt_at = now(), + last_error = NULL, + missing_since = NULL, + updated_at = now() + `, + [JSON.stringify(payload)] + ); +} + +/** + * Registra que a estas bases NO se les pudo preguntar (servidor inalcanzable, credenciales, tope). + * + * Deliberadamente **no toca las métricas ni `measured_at`**: el último valor conocido se conserva y + * la UI lo muestra marcando su antigüedad. Solo deja constancia del intento y del motivo. Si el + * nodo nunca se midió, se crea la fila con métricas en NULL para que igual se sepa que se intentó. + */ +export async function markNodeMetricsAttempt( + databaseNodeIds: number[], + error: string +): Promise { + if (databaseNodeIds.length === 0) return; + await ensureNodeMetricsCacheTable(); + await pgPool.query( + ` + INSERT INTO ${qNodeMetricsCache()} (database_node_id, last_attempt_at, last_error, updated_at) + SELECT id, now(), $2, now() FROM unnest($1::int[]) AS id + ON CONFLICT (database_node_id) DO UPDATE SET + last_attempt_at = now(), + last_error = EXCLUDED.last_error, + updated_at = now() + `, + [databaseNodeIds, error.slice(0, 500)] + ); +} + +/** + * Registra que se conectó al servidor y la base YA NO ESTÁ ahí. + * + * Es lo contrario del caso anterior y por eso se separa: aquí sí hubo respuesta, así que dejar el + * tamaño viejo sería mentir para siempre. Se limpian las métricas y se sella `missing_since` la + * primera vez (COALESCE), para poder decir desde cuándo falta. + */ +export async function markNodeMetricsMissing(databaseNodeIds: number[]): Promise { + if (databaseNodeIds.length === 0) return; + await ensureNodeMetricsCacheTable(); + await pgPool.query( + ` + INSERT INTO ${qNodeMetricsCache()} AS nmc ( + database_node_id, measured_at, last_attempt_at, missing_since, updated_at + ) + SELECT id, now(), now(), now(), now() FROM unnest($1::int[]) AS id + ON CONFLICT (database_node_id) DO UPDATE SET + size_mb = NULL, + total_size_gb = NULL, + state_desc = NULL, + recovery_model_desc = NULL, + measured_at = now(), + last_attempt_at = now(), + last_error = NULL, + missing_since = COALESCE(nmc.missing_since, now()), + updated_at = now() + `, + [databaseNodeIds] + ); +} + +/** Borra las filas de nodos que ya no existen en el catálogo. Devuelve cuántas quitó. */ +export async function pruneNodeMetricsCache(keepNodeIds: number[]): Promise { + try { + await ensureNodeMetricsCacheTable(); + const r = await pgPool.query( + `DELETE FROM ${qNodeMetricsCache()} WHERE NOT (database_node_id = ANY($1::int[]))`, + [keepNodeIds] + ); + return r.rowCount ?? 0; + } catch (e) { + if (isPgUndefinedTable(e)) return 0; + throw e; + } +} + // ============================================================================ // Inventario "último respaldo por nodo" (node_last_restore) y restores fallidos. // ============================================================================ diff --git a/src/lib/server/cras-monitor-config.ts b/src/lib/server/cras-monitor-config.ts new file mode 100644 index 0000000..13707c1 --- /dev/null +++ b/src/lib/server/cras-monitor-config.ts @@ -0,0 +1,171 @@ +/** + * Configuración del monitor periódico de servidores de restauración. + * + * El parseo está separado de la lectura del entorno a propósito: `parseMonitorConfig` es pura y se + * prueba con un record cualquiera, y `monitorConfig()` es el único punto que toca + * `$env/dynamic/private`. Se lee en CADA barrido y no una sola vez al arrancar, para que cambiar + * el intervalo o los destinatarios no exija reiniciar el proceso (aunque en Docker sí exija + * recrear el contenedor: `environment:` congela los valores al crearlo). + * + * Todos los números se saneen con topes: un `NaN` en `setTimeout` no espera, dispara de inmediato, + * y con el timer re-armado en el `finally` del tick eso sería un bucle de barridos por SSH. + */ +import { env } from '$env/dynamic/private'; + +export interface CrasMonitorConfig { + /** Kill-switch. Apagado por omisión: `npm run dev` no debe hacer SSH a producción. */ + enabled: boolean; + /** Periodo entre barridos, contado desde que TERMINA el anterior. */ + intervalMs: number; + /** Espera tras arrancar el proceso, para no barrer en medio de un despliegue. */ + initialDelayMs: number; + /** Sesiones SSH simultáneas durante un barrido. */ + concurrency: number; + /** Tope por destino. Acota el barrido, no la sesión SSH (ver withTimeout). */ + targetTimeoutMs: number; + /** Fallas consecutivas antes del primer aviso. 1 = avisar de inmediato. */ + failureThreshold: number; + /** Anti-spam: tiempo mínimo entre re-avisos del mismo destino. */ + cooldownMs: number; + /** Incluir los diagnósticos de configuración en el correo (no solo en la tabla). */ + notifyConfigIssues: boolean; + recipients: string[]; + /** Destinos excluidos del barrido (p. ej. un servidor apagado a propósito). */ + ignoredTargetIds: number[]; + /** Base para el enlace del correo. Vacía = el correo va sin enlace. */ + panelUrl: string; +} + +const DEFAULT_INTERVAL_MINUTES = 10; +const DEFAULT_INITIAL_DELAY_MS = 60_000; +const DEFAULT_CONCURRENCY = 3; +const DEFAULT_TARGET_TIMEOUT_MS = 90_000; +const DEFAULT_FAILURE_THRESHOLD = 2; +const DEFAULT_COOLDOWN_HOURS = 6; + +/** `'1' | 'true' | 'yes' | 'si' | 'sí' | 'on'` en cualquier caja. Todo lo demás es falso. */ +export function parseBoolFlag(raw: string | undefined, fallback: boolean): boolean { + const value = (raw ?? '').trim().toLowerCase(); + if (!value) return fallback; + return ['1', 'true', 'yes', 'si', 'sí', 'on'].includes(value); +} + +/** + * Entero acotado a [min, max]. Un valor no numérico cae al default en lugar de a `NaN`, y uno + * fuera de rango se recorta al extremo: es preferible un monitor que barre cada minuto a uno que + * no barre nunca por un cero mal escrito. + */ +export function parseIntInRange( + raw: string | undefined, + fallback: number, + min: number, + max: number +): number { + const text = (raw ?? '').trim(); + if (!text) return fallback; + const value = Number(text); + if (!Number.isFinite(value)) return fallback; + const rounded = Math.trunc(value); + if (rounded < min) return min; + if (rounded > max) return max; + return rounded; +} + +/** + * Destinatarios separados por coma o punto y coma. Se descarta lo que no parezca correo y se + * deduplica sin distinguir caja, con el mismo criterio que `dedupeEmails` de /api/alerts/send. + */ +export function parseRecipients(raw: string | undefined): string[] { + const seen = new Set(); + const result: string[] = []; + for (const part of (raw ?? '').split(/[,;]/)) { + const addr = part.trim(); + if (!addr || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(addr)) continue; + const key = addr.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + result.push(addr); + } + return result; +} + +/** Ids separados por coma. Lo que no sea un entero positivo se ignora en silencio. */ +export function parseIdList(raw: string | undefined): number[] { + const seen = new Set(); + for (const part of (raw ?? '').split(/[,;]/)) { + const text = part.trim(); + if (!text) continue; + const value = Number(text); + if (!Number.isInteger(value) || value <= 0) continue; + seen.add(value); + } + return [...seen].sort((a, b) => a - b); +} + +export function parseMonitorConfig(raw: Record): CrasMonitorConfig { + const intervalMinutes = parseIntInRange( + raw.CRAS_MONITOR_INTERVAL_MINUTES, + DEFAULT_INTERVAL_MINUTES, + 1, + 1440 + ); + const cooldownHours = parseIntInRange( + raw.CRAS_MONITOR_COOLDOWN_HOURS, + DEFAULT_COOLDOWN_HOURS, + 1, + 168 + ); + return { + enabled: parseBoolFlag(raw.CRAS_MONITOR_ENABLED, false), + intervalMs: intervalMinutes * 60_000, + initialDelayMs: parseIntInRange( + raw.CRAS_MONITOR_INITIAL_DELAY_MS, + DEFAULT_INITIAL_DELAY_MS, + 0, + 3_600_000 + ), + concurrency: parseIntInRange(raw.CRAS_MONITOR_CONCURRENCY, DEFAULT_CONCURRENCY, 1, 10), + // El piso son 30 s porque la sonda ya gasta 6 s de TCP + 15 s de handshake antes del + // primer comando: por debajo de eso el tope mataría barridos que iban a salir bien. + targetTimeoutMs: parseIntInRange( + raw.CRAS_MONITOR_TARGET_TIMEOUT_MS, + DEFAULT_TARGET_TIMEOUT_MS, + 30_000, + 600_000 + ), + failureThreshold: parseIntInRange( + raw.CRAS_MONITOR_FAILURE_THRESHOLD, + DEFAULT_FAILURE_THRESHOLD, + 1, + 10 + ), + cooldownMs: cooldownHours * 3_600_000, + notifyConfigIssues: parseBoolFlag(raw.CRAS_MONITOR_NOTIFY_CONFIG, false), + recipients: parseRecipients(raw.CRAS_MONITOR_TO), + ignoredTargetIds: parseIdList(raw.CRAS_MONITOR_IGNORE_TARGET_IDS), + panelUrl: (raw.PANEL_PUBLIC_URL || raw.ORIGIN || '').trim().replace(/\/+$/, '') + }; +} + +export function monitorConfig(): CrasMonitorConfig { + return parseMonitorConfig(env); +} + +/** + * Por qué el monitor no podría notificar con esta configuración. + * + * Se consulta al arrancar y el scheduler NO se programa si devuelve algo: un monitor que sondea + * pero no puede avisar es peor que no tener monitor, porque da una falsa sensación de vigilancia + * mientras el incidente pasa desapercibido. + */ +export function configProblems(cfg: CrasMonitorConfig, smtpReady: boolean): string[] { + const problems: string[] = []; + if (!cfg.enabled) return problems; + if (cfg.recipients.length === 0) { + problems.push('CRAS_MONITOR_TO está vacío: no hay a quién avisar'); + } + if (!smtpReady) { + problems.push('falta configuración SMTP (SMTP_HOST y SMTP_FROM o SMTP_USER)'); + } + return problems; +} diff --git a/src/lib/server/cras-monitor-core.ts b/src/lib/server/cras-monitor-core.ts new file mode 100644 index 0000000..87a17f7 --- /dev/null +++ b/src/lib/server/cras-monitor-core.ts @@ -0,0 +1,322 @@ +/** + * Lógica pura del monitor de servidores de restauración: clasificar un diagnóstico, decidir si + * toca avisar, y las dos utilidades de control de flujo del barrido. + * + * Aquí no hay PostgreSQL, ni SSH, ni SMTP. Todo lo que decide *si* se manda un correo vive en este + * archivo justamente para poder fijarlo con pruebas sin mocks: la parte caro-de-equivocarse del + * monitor no es sondear, es no avisar cuando había que avisar (o avisar cada 10 minutos para + * siempre), y eso se decide aquí. + */ +import type { VerifyDiagnosis } from './cras-verify'; + +/** + * `sin_evaluar` es el estado inicial de una fila creada por una omisión: el destino existe, se + * intentó, y todavía no hay veredicto. No lo devuelve `classifyDiagnosis`; lo pone el DEFAULT de + * la columna. Sin él, un destino omitido desde el primer barrido se leería como `ok`, que es + * exactamente lo que no se sabe. + */ +export type MonitorSeverity = 'sin_evaluar' | 'ok' | 'caido' | 'inalcanzable' | 'configuracion'; + +export type MonitorEmailKind = 'caido' | 'restablecido'; + +/** Qué se hizo con un destino en un barrido. Se cuenta y se registra; es la bitácora del monitor. */ +export type MonitorReason = + | 'sin_cambio_ok' + | 'omitido_instalacion' + | 'omitido_sin_credenciales' + | 'omitido_ignorado' + | 'configuracion_sin_aviso' + | 'falla_bajo_umbral' + | 'alerta_nueva' + | 'alerta_reenvio' + | 'alerta_en_cooldown' + | 'restablecido' + | 'recuperado_sin_aviso'; + +/** Las razones que corresponden a un destino que no se sondeó. */ +export type MonitorSkipReason = Extract< + MonitorReason, + 'omitido_instalacion' | 'omitido_sin_credenciales' | 'omitido_ignorado' +>; + +/** + * Contadores del reporte, todos en cero. + * + * Es un `Record` literal a propósito: si se agrega una razón a la unión y no + * se agrega aquí, no compila. Un contador ausente se serializa como `undefined` y en el log se lee + * igual que un cero, cuando en realidad significa "nadie lo contó". + */ +export function emptyReasonCounters(): Record { + return { + sin_cambio_ok: 0, + omitido_instalacion: 0, + omitido_sin_credenciales: 0, + omitido_ignorado: 0, + configuracion_sin_aviso: 0, + falla_bajo_umbral: 0, + alerta_nueva: 0, + alerta_reenvio: 0, + alerta_en_cooldown: 0, + restablecido: 0, + recuperado_sin_aviso: 0 + }; +} + +/** Todas las razones. Derivada del record para que no puedan divergir. */ +export const MONITOR_REASONS = Object.keys(emptyReasonCounters()) as MonitorReason[]; + +/** + * Mapa TOTAL de diagnóstico a severidad, y no un `switch` con `default`, a propósito: si mañana se + * agrega un diagnóstico a `VerifyDiagnosis`, este record deja de compilar en lugar de clasificarlo + * en silencio como `ok` (nunca avisaría) o como caída (avisaría para siempre). + * + * El corte entre "caída" y "configuración" es la decisión de producto del monitor: solo se avisa de + * lo que se restablece reiniciando algo. Un servidor sin credenciales capturadas o sin el agente + * instalado es un pendiente de alguien, no un incidente, y mandaría el mismo correo cada cooldown + * hasta que alguien lo atienda — que es como se entrena a un operador a ignorar las alertas. + */ +const SEVERITY_BY_DIAGNOSIS: Record = { + ok: 'ok', + // El agente no está corriendo. Es el caso central del monitor. + servicio_detenido: 'caido', + // No se pudo comprobar el servidor: apagado, sin red, o sshd detenido. + host_no_responde: 'inalcanzable', + puerto_cerrado: 'inalcanzable', + error: 'inalcanzable', + // Pendientes de configuración: persisten hasta que una persona los arregla a mano. + sin_credenciales: 'configuracion', + credenciales_rechazadas: 'configuracion', + sin_instalar: 'configuracion', + sin_privilegios: 'configuracion', + sin_ejecucion: 'configuracion', + posix_en_host_windows: 'configuracion' +}; + +export function classifyDiagnosis(diagnosis: VerifyDiagnosis): MonitorSeverity { + return SEVERITY_BY_DIAGNOSIS[diagnosis]; +} + +/** ¿Esta severidad amerita correo? `configuracion` solo si el operador lo pidió explícitamente. */ +export function isAlertableSeverity( + severity: MonitorSeverity, + notifyConfigIssues: boolean +): boolean { + if (severity === 'caido' || severity === 'inalcanzable') return true; + if (severity === 'configuracion') return notifyConfigIssues; + return false; +} + +/** Espejo de una fila de `a24c.cras_monitor_state`, con los timestamps ya en ISO. */ +export interface MonitorStateRow { + restore_target_id: number; + target_name: string | null; + severity: MonitorSeverity; + diagnosis: VerifyDiagnosis | null; + summary: string | null; + consecutive_failures: number; + alerted: boolean; + notify_count: number; + first_failure_at: string | null; + last_notified_at: string | null; + last_checked_at: string | null; + last_ok_at: string | null; + last_skip_reason: string | null; +} + +/** + * Lo que hay que escribir del destino, pase lo que pase con el correo. + * + * Ojo con lo que NO trae: `alerted`, `notify_count` ni `last_notified_at`. Esos tres solo se tocan + * después de un envío exitoso (`markMonitorNotified`) o al cerrar el incidente + * (`clearMonitorAlert`). La separación es lo que hace imposible el bug de avanzar el cooldown sin + * haber avisado —que dejaría al monitor mudo justo cuando importa—, y por eso es de tipo y no de + * disciplina. + */ +export interface MonitorObservation { + severity: MonitorSeverity; + diagnosis: VerifyDiagnosis; + summary: string; + consecutiveFailures: number; + firstFailureAt: string | null; + lastOkAt: string | null; + /** El incidente se cierra: hay que llamar `clearMonitorAlert` además de guardar esto. */ + clearAlert: boolean; +} + +export interface DecisionInput { + previous: MonitorStateRow | null; + diagnosis: VerifyDiagnosis; + summary: string; + now: Date; + failureThreshold: number; + cooldownMs: number; + notifyConfigIssues: boolean; +} + +export interface MonitorDecision { + reason: MonitorReason; + severity: MonitorSeverity; + /** Qué correo toca, si toca alguno. */ + email: MonitorEmailKind | null; + observation: MonitorObservation; +} + +/** Milisegundos desde `iso`. `null` o fecha inválida = infinito, para que nunca frene un aviso. */ +function elapsedSince(iso: string | null | undefined, now: Date): number { + if (!iso) return Number.POSITIVE_INFINITY; + const then = new Date(iso).getTime(); + if (!Number.isFinite(then)) return Number.POSITIVE_INFINITY; + return now.getTime() - then; +} + +/** + * Decide qué hacer con un destino ya sondeado. No sabe de destinos omitidos: esos no llegan aquí + * porque no hay veredicto que interpretar (los maneja el barrido con `saveMonitorSkip`). + * + * La tabla completa de transiciones está en el plan; los tres casos que no son obvios: + * + * - Una recuperación **sin aviso previo** no manda correo de "restablecido". Es la contraparte del + * umbral: si el parpadeo no generó alerta, tampoco debe generar la carta de disculpa. + * - Un cambio de diagnóstico **no** rompe el cooldown. Un servidor que oscila entre + * `host_no_responde` y `puerto_cerrado` mandaría un correo por barrido; el diagnóstico nuevo se + * guarda y viaja en el próximo re-aviso. + * - Un diagnóstico de configuración con el aviso apagado **congela** los contadores en lugar de + * reiniciarlos. Reiniciarlos permitiría que un destino alternando entre `servicio_detenido` y + * `sin_instalar` nunca cruzara el umbral. + */ +export function decideMonitorAction(input: DecisionInput): MonitorDecision { + const { previous, diagnosis, summary, now, failureThreshold, cooldownMs, notifyConfigIssues } = + input; + const severity = classifyDiagnosis(diagnosis); + const nowIso = now.toISOString(); + const wasAlerted = previous?.alerted ?? false; + const previousFailures = previous?.consecutive_failures ?? 0; + + if (severity === 'ok') { + const observation: MonitorObservation = { + severity, + diagnosis, + summary, + consecutiveFailures: 0, + firstFailureAt: null, + lastOkAt: nowIso, + clearAlert: true + }; + if (wasAlerted) return { reason: 'restablecido', severity, email: 'restablecido', observation }; + if (previousFailures > 0) { + return { reason: 'recuperado_sin_aviso', severity, email: null, observation }; + } + return { reason: 'sin_cambio_ok', severity, email: null, observation }; + } + + if (!isAlertableSeverity(severity, notifyConfigIssues)) { + // Se registra el veredicto nuevo pero no se toca nada del incidente: ni contadores ni + // `alerted`. Un destino que estaba alertado y ahora reporta un pendiente de configuración + // sigue alertado —no se ha restablecido—, y cerrará su incidente cuando vuelva a `ok`. + return { + reason: 'configuracion_sin_aviso', + severity, + email: null, + observation: { + severity, + diagnosis, + summary, + consecutiveFailures: previousFailures, + firstFailureAt: previous?.first_failure_at ?? null, + lastOkAt: previous?.last_ok_at ?? null, + clearAlert: false + } + }; + } + + const consecutiveFailures = previousFailures + 1; + const observation: MonitorObservation = { + severity, + diagnosis, + summary, + consecutiveFailures, + firstFailureAt: previous?.first_failure_at ?? nowIso, + lastOkAt: previous?.last_ok_at ?? null, + clearAlert: false + }; + + if (wasAlerted) { + const elapsed = elapsedSince(previous?.last_notified_at, now); + if (elapsed >= cooldownMs) { + return { reason: 'alerta_reenvio', severity, email: 'caido', observation }; + } + return { reason: 'alerta_en_cooldown', severity, email: null, observation }; + } + + if (consecutiveFailures >= failureThreshold) { + return { reason: 'alerta_nueva', severity, email: 'caido', observation }; + } + return { reason: 'falla_bajo_umbral', severity, email: null, observation }; +} + +// ============================================================================ +// Control de flujo del barrido +// ============================================================================ + +/** + * Aplica `worker` a `items` con a lo más `limit` en vuelo. Los resultados salen en el orden de + * entrada, no en el de terminación. + * + * Es el equivalente del `ThreadPoolExecutor(max_workers=N)` que usa el monitor de respaldos de + * a24c: N consumidores tomando del mismo cursor. Frente a trocear en lotes con `Promise.all`, no + * deja consumidores ociosos esperando al lento del lote — con sondas SSH que van de 2 a 90 s la + * diferencia es el doble de duración del barrido. + * + * Contrato: `worker` NUNCA debe rechazar. Esta utilidad no aísla rechazos; si uno rechaza, el + * `Promise.all` de abajo rechaza mientras los demás siguen corriendo sin dueño. Quien la usa + * captura sus propios errores (en el barrido eso lo hace `probeTarget`). + */ +export async function mapWithConcurrency( + items: readonly T[], + limit: number, + worker: (item: T, index: number) => Promise +): Promise { + const size = items.length; + const results = new Array(size); + if (size === 0) return results; + + const workers = Math.max(1, Math.min(Math.trunc(limit) || 1, size)); + let cursor = 0; + + const pump = async (): Promise => { + for (;;) { + const index = cursor++; + if (index >= size) return; + results[index] = await worker(items[index], index); + } + }; + + await Promise.all(Array.from({ length: workers }, pump)); + return results; +} + +/** + * Resuelve con `onTimeout()` si `work` no termina en `ms`. + * + * Ojo con lo que esto NO hace: `Promise.race` no cancela `work`. La sesión SSH abandonada sigue + * viva hasta que vencen sus propios timeouts (6 s de TCP, 15 s de handshake, 20 s por comando) y se + * cierra en el `finally` de `verifyCrasTarget`. Este tope acota la duración del BARRIDO, no el + * recurso remoto; sirve para que la cadencia sea predecible, no para liberar conexiones. + */ +export function withTimeout(work: Promise, ms: number, onTimeout: () => T): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + resolve(onTimeout()); + }, ms); + work.then( + (value) => { + clearTimeout(timer); + resolve(value); + }, + (err) => { + clearTimeout(timer); + reject(err); + } + ); + }); +} diff --git a/src/lib/server/cras-monitor-email.ts b/src/lib/server/cras-monitor-email.ts new file mode 100644 index 0000000..c535ece --- /dev/null +++ b/src/lib/server/cras-monitor-email.ts @@ -0,0 +1,239 @@ +/** + * Cuerpos de los correos del monitor de servidores de restauración. + * + * Es un **digest por barrido**, no un correo por servidor. Cuando se cae la red de un sitio, cuatro + * servidores caen juntos: cuatro correos casi idénticos entierran el siguiente incidente real y el + * operador aprende a archivarlos sin leerlos. El cooldown sigue siendo por destino, así que uno que + * ya avisó hace poco simplemente no entra al digest. + * + * Puro: se prueba sin mocks. Reusa el cascarón de marca de `email-service` en lugar de copiarlo. + */ +import { escHtml, renderBrandEmail, formatFechaEs } from './email-service'; +import type { MonitorSeverity } from './cras-monitor-core'; +import type { VerifyDiagnosis, VerifyResult } from './cras-verify'; + +/** + * Lo único que viaja por correo de cada destino. + * + * No hay `checks[]` a propósito: el detalle por chequeo incluye + * `usuario@host:puerto` (ver el check `credenciales` de cras-verify) y el correo sale del panel + * hacia un relevo SMTP que no controlamos. El diagnóstico y el resumen alcanzan para decidir si hay + * que levantarse a atenderlo; el detalle está a un clic, detrás de la sesión del panel. + */ +export interface MonitorEmailItem { + restore_target_id: number; + name: string; + platform: string | null; + diagnosis: VerifyDiagnosis; + severity: MonitorSeverity; + summary: string; + remediation_title: string | null; + checked_at: string; + consecutive_failures: number; +} + +export interface RenderedEmail { + subject: string; + plainBody: string; + htmlBody: string; +} + +const DIAGNOSIS_LABEL: Record = { + ok: 'Disponible', + servicio_detenido: 'El agente no está corriendo', + host_no_responde: 'El host no responde', + puerto_cerrado: 'El servicio SSH está detenido', + error: 'Error al verificar', + sin_credenciales: 'Sin credenciales SSH capturadas', + credenciales_rechazadas: 'Credenciales SSH rechazadas', + sin_instalar: 'El agente no está instalado', + sin_privilegios: 'Sin privilegios para instalar', + sin_ejecucion: 'La cuenta SSH no puede ejecutar comandos', + posix_en_host_windows: 'La sesión SSH entra a un subsistema POSIX' +}; + +const SEVERITY_LEAD: Record = { + sin_evaluar: 'No se pudo evaluar el servidor.', + ok: 'El servidor está disponible.', + caido: 'El agente está instalado pero no está corriendo.', + inalcanzable: 'El panel no pudo comprobar el servidor.', + configuracion: 'Hay un pendiente de configuración en el servidor.' +}; + +export function diagnosisLabel(diagnosis: VerifyDiagnosis): string { + return DIAGNOSIS_LABEL[diagnosis] ?? diagnosis; +} + +function escapeRegExp(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * Quita del texto los valores de conexión del destino antes de mandarlo por correo. + * + * Dos capas, y las dos hacen falta. La literal borra lo que sabemos del inventario (host SSH, + * usuario, IP del SQL) y no tiene falsos positivos. La de IPv4 es el respaldo para lo que no + * sabemos: cuando la sonda falla por red, el mensaje del sistema operativo trae la dirección que + * **resolvió**, que no siempre es la que está capturada (`connect ECONNREFUSED 10.0.20.5:22`). + */ +export function redactSecrets(text: string, secrets: readonly (string | null)[]): string { + let out = text; + for (const secret of secrets) { + const value = (secret ?? '').trim(); + // Se ignoran los valores muy cortos: un usuario "sa" o un host "a" convertirían el mensaje + // en un tachón ilegible al reemplazar cada aparición de esas letras dentro de otra palabra. + if (value.length < 4) continue; + out = out.replace(new RegExp(escapeRegExp(value), 'gi'), '«omitido»'); + } + return out + .replace(/\b[\w.+-]+@[\w.-]+\b/g, '«omitido»') + .replace(/\b\d{1,3}(?:\.\d{1,3}){3}\b(?::\d{1,5})?/g, '«omitido»'); +} + +/** + * Convierte el resultado de la sonda en lo que viaja por correo, ya saneado. + * + * `secrets` son los valores de conexión del destino que hay que borrar del resumen. Es un parámetro + * y no una lectura interna porque este módulo es puro y no consulta la base. + */ +export function toEmailItem( + result: VerifyResult, + consecutiveFailures: number, + severity: MonitorSeverity, + secrets: readonly (string | null)[] = [] +): MonitorEmailItem { + return { + restore_target_id: result.restore_target_id, + name: result.name, + platform: result.platform, + diagnosis: result.diagnosis, + severity, + summary: redactSecrets(result.summary, secrets), + remediation_title: result.remediation?.title ?? null, + checked_at: result.checked_at, + consecutive_failures: consecutiveFailures + }; +} + +function panelLinkHtml(panelUrl: string): string { + if (!panelUrl) return ''; + const href = escHtml(`${panelUrl}/versiones-cras`); + return `

Ver el detalle y el remedio en el panel

`; +} + +function panelLinkText(panelUrl: string): string { + if (!panelUrl) return ''; + return `\nDetalle y remedio: ${panelUrl}/versiones-cras\n`; +} + +function itemRowsHtml(items: MonitorEmailItem[], accent: string): string { + return items + .map((item) => { + const plataforma = item.platform ? ` (${escHtml(item.platform)})` : ''; + const remedio = item.remediation_title + ? `
Remedio: ${escHtml(item.remediation_title)}
` + : ''; + const fallas = + item.consecutive_failures > 1 + ? `
Fallas consecutivas: ${item.consecutive_failures}
` + : ''; + return ` +
+
${escHtml(item.name)}${plataforma}
+
${escHtml(diagnosisLabel(item.diagnosis))}
+
${escHtml(item.summary)}
+ ${remedio} + ${fallas} +
Verificado el ${escHtml(formatFechaEs(item.checked_at))}
+
`; + }) + .join(''); +} + +function itemRowsText(items: MonitorEmailItem[]): string { + return items + .map((item) => { + const lines = [ + `- ${item.name}${item.platform ? ` (${item.platform})` : ''}`, + ` ${diagnosisLabel(item.diagnosis)}`, + ` ${item.summary}` + ]; + if (item.remediation_title) lines.push(` Remedio: ${item.remediation_title}`); + if (item.consecutive_failures > 1) { + lines.push(` Fallas consecutivas: ${item.consecutive_failures}`); + } + lines.push(` Verificado el ${formatFechaEs(item.checked_at)}`); + return lines.join('\n'); + }) + .join('\n'); +} + +/** Agrupa el arranque del correo según lo que domine: agente detenido vs. servidor inalcanzable. */ +function leadFor(items: MonitorEmailItem[]): string { + const severities = new Set(items.map((i) => i.severity)); + if (severities.size === 1) { + const [only] = [...severities]; + return SEVERITY_LEAD[only]; + } + return 'Los servidores afectados no están en el mismo estado; el detalle de cada uno va abajo.'; +} + +export function buildDownDigestEmail(items: MonitorEmailItem[], panelUrl: string): RenderedEmail { + const n = items.length; + const subject = + n === 1 + ? `Servidor de restauración sin servicio — ${items[0].name}` + : `${n} servidores de restauración sin servicio`; + + const titulo = n === 1 ? 'Servidor de restauración sin servicio' : 'Servidores de restauración sin servicio'; + const inner = ` +

${escHtml(titulo)}

+

${escHtml(leadFor(items))}

+ ${itemRowsHtml(items, '#dc3545')} +

Mientras el agente no corra, ese servidor no restaura respaldos: los archivos se acumulan en su carpeta de Entrada.

+ ${panelLinkHtml(panelUrl)}`; + + const plainBody = [ + titulo.toUpperCase(), + '', + leadFor(items), + '', + itemRowsText(items), + '', + 'Mientras el agente no corra, ese servidor no restaura respaldos: los archivos se acumulan en su carpeta de Entrada.', + panelLinkText(panelUrl), + '© TransmitirAS — aviso automático del panel.' + ].join('\n'); + + return { subject, plainBody, htmlBody: renderBrandEmail(inner) }; +} + +export function buildRecoveredDigestEmail( + items: MonitorEmailItem[], + panelUrl: string +): RenderedEmail { + const n = items.length; + const subject = + n === 1 + ? `Servidor de restauración restablecido — ${items[0].name}` + : `${n} servidores de restauración restablecidos`; + + const titulo = n === 1 ? 'Servidor de restauración restablecido' : 'Servidores de restauración restablecidos'; + const inner = ` +

${escHtml(titulo)}

+

La verificación volvió a responder correctamente. No se requiere ninguna acción.

+ ${itemRowsHtml(items, '#28a745')} + ${panelLinkHtml(panelUrl)}`; + + const plainBody = [ + titulo.toUpperCase(), + '', + 'La verificación volvió a responder correctamente. No se requiere ninguna acción.', + '', + itemRowsText(items), + panelLinkText(panelUrl), + '© TransmitirAS — aviso automático del panel.' + ].join('\n'); + + return { subject, plainBody, htmlBody: renderBrandEmail(inner) }; +} diff --git a/src/lib/server/cras-monitor-state.ts b/src/lib/server/cras-monitor-state.ts new file mode 100644 index 0000000..863ef9e --- /dev/null +++ b/src/lib/server/cras-monitor-state.ts @@ -0,0 +1,278 @@ +/** + * Estado persistente del monitor de servidores de restauración: una fila por destino con el último + * veredicto, los contadores del umbral y el cooldown del aviso. + * + * Único módulo de la feature que toca `pgPool`. El resto es puro o solo orquesta. + * + * **El dueño de `a24c.cras_monitor_state` es el panel.** A diferencia de `cras_releases` o + * `cras_install_runs`, esta tabla NO tiene migración de alembic en a24c: el panel es su único + * escritor y su único lector. Si alguien agrega una migración divergente, el orden de arranque + * decidiría qué columnas quedan —el `CREATE TABLE IF NOT EXISTS` de aquí vería la tabla existente y + * no la tocaría—, que es exactamente la grieta que documenta el encabezado de cras-releases.ts. + */ +import { pgPool } from './db'; +import { logger } from './logger'; +import { isPgUndefinedTable } from './cras-releases'; +import type { + MonitorObservation, + MonitorSkipReason, + MonitorStateRow, + MonitorSeverity +} from './cras-monitor-core'; +import type { VerifyDiagnosis } from './cras-verify'; + +const SCHEMA = 'a24c'; + +function qMonitorState(): string { + return `"${SCHEMA}"."cras_monitor_state"`; +} + +function qRestoreTargets(): string { + return `"${SCHEMA}"."restore_targets"`; +} + +/** + * Llave del advisory lock del barrido. Arbitraria y constante; solo tiene que no chocar con otra + * llave del mismo servidor PostgreSQL (a24c no usa advisory locks). + */ +const SWEEP_LOCK_KEY = 724301; + +/** + * DDL idempotente, con el mismo patrón de `ensureCrasSchema`: se intenta con la clave foránea y se + * cae a la variante sin ella si `restore_targets` todavía no existe. Es preferible un estado sin + * integridad referencial a que el panel no arranque. + */ +export async function ensureCrasMonitorSchema(): Promise { + await pgPool.query('CREATE SCHEMA IF NOT EXISTS a24c'); + + // ON DELETE CASCADE, y no SET NULL como la bitácora de instalaciones: una fila de estado sin + // destino no significa nada, y un destino borrado con alerted=true dejaría un incidente abierto + // para siempre. `pruneMonitorStates` es la red de seguridad cuando la FK no se pudo crear. + const ddl = (withFk: boolean) => ` + CREATE TABLE IF NOT EXISTS ${qMonitorState()} ( + restore_target_id INTEGER PRIMARY KEY${ + withFk ? ` REFERENCES ${qRestoreTargets()} (id) ON DELETE CASCADE` : '' + }, + target_name VARCHAR(120), + severity VARCHAR(20) NOT NULL DEFAULT 'sin_evaluar', + diagnosis VARCHAR(40), + summary TEXT, + consecutive_failures INTEGER NOT NULL DEFAULT 0, + alerted BOOLEAN NOT NULL DEFAULT FALSE, + notify_count INTEGER NOT NULL DEFAULT 0, + first_failure_at TIMESTAMPTZ, + last_notified_at TIMESTAMPTZ, + last_checked_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_ok_at TIMESTAMPTZ, + last_skip_reason VARCHAR(40), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT cras_monitor_state_severity_check + CHECK (severity IN ('sin_evaluar', 'ok', 'caido', 'inalcanzable', 'configuracion')) + ) + `; + try { + await pgPool.query(ddl(true)); + } catch (e) { + if (!isPgUndefinedTable(e)) throw e; + logger.warn({ + message: 'cras_monitor_state creada sin clave foránea: falta a24c.restore_targets', + context: { hint: 'el estado de destinos borrados lo limpia pruneMonitorStates' } + }); + await pgPool.query(ddl(false)); + } + + // Parcial: la consulta que importa es "qué incidentes están abiertos ahora". + await pgPool.query( + `CREATE INDEX IF NOT EXISTS idx_a24c_cras_monitor_state_alerted + ON ${qMonitorState()} (last_notified_at DESC) WHERE alerted` + ); +} + +/** `pg` devuelve `Date` para TIMESTAMPTZ; el resto de la feature trabaja con ISO. */ +function isoOrNull(value: unknown): string | null { + if (value instanceof Date) return value.toISOString(); + if (typeof value === 'string' && value) return value; + return null; +} + +export async function listMonitorStates(): Promise { + const r = await pgPool.query(` + SELECT restore_target_id, target_name, severity, diagnosis, summary, + consecutive_failures, alerted, notify_count, + first_failure_at, last_notified_at, last_checked_at, last_ok_at, last_skip_reason + FROM ${qMonitorState()} + ORDER BY target_name, restore_target_id + `); + return r.rows.map((row) => ({ + restore_target_id: Number(row.restore_target_id), + target_name: row.target_name ?? null, + severity: row.severity as MonitorSeverity, + diagnosis: (row.diagnosis ?? null) as VerifyDiagnosis | null, + summary: row.summary ?? null, + consecutive_failures: Number(row.consecutive_failures ?? 0), + alerted: Boolean(row.alerted), + notify_count: Number(row.notify_count ?? 0), + first_failure_at: isoOrNull(row.first_failure_at), + last_notified_at: isoOrNull(row.last_notified_at), + last_checked_at: isoOrNull(row.last_checked_at), + last_ok_at: isoOrNull(row.last_ok_at), + last_skip_reason: row.last_skip_reason ?? null + })); +} + +/** + * Guarda el veredicto de un destino sondeado. + * + * **No toca `alerted`, `notify_count` ni `last_notified_at`**, ni aquí ni en el `DO UPDATE`. Esos + * tres son del cooldown y solo se mueven tras un envío exitoso (`markMonitorNotified`) o al cerrar + * el incidente (`clearMonitorAlert`). Si esta sentencia los tocara, un SMTP caído dejaría el + * cooldown avanzado sin haber avisado y el monitor quedaría mudo hasta la siguiente caída. + */ +export async function saveMonitorObservation( + restoreTargetId: number, + targetName: string, + observation: MonitorObservation +): Promise { + await pgPool.query( + `INSERT INTO ${qMonitorState()} + (restore_target_id, target_name, severity, diagnosis, summary, + consecutive_failures, first_failure_at, last_ok_at, + last_checked_at, last_skip_reason, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, now(), NULL, now()) + ON CONFLICT (restore_target_id) DO UPDATE SET + target_name = EXCLUDED.target_name, + severity = EXCLUDED.severity, + diagnosis = EXCLUDED.diagnosis, + summary = EXCLUDED.summary, + consecutive_failures = EXCLUDED.consecutive_failures, + first_failure_at = EXCLUDED.first_failure_at, + last_ok_at = EXCLUDED.last_ok_at, + last_checked_at = now(), + last_skip_reason = NULL, + updated_at = now()`, + [ + restoreTargetId, + targetName, + observation.severity, + observation.diagnosis, + observation.summary, + observation.consecutiveFailures, + observation.firstFailureAt, + observation.lastOkAt + ] + ); +} + +/** + * Registra únicamente el intento en un destino omitido. + * + * No pisa `severity`, `diagnosis` ni los contadores a propósito: si los pisara, un destino ya + * alertado que se omite —porque le empezó una instalación— y luego vuelve a `ok` perdería su correo + * de "restablecido", y el operador se quedaría creyendo que sigue caído. + */ +export async function saveMonitorSkip( + restoreTargetId: number, + targetName: string, + reason: MonitorSkipReason +): Promise { + await pgPool.query( + `INSERT INTO ${qMonitorState()} + (restore_target_id, target_name, last_checked_at, last_skip_reason, updated_at) + VALUES ($1, $2, now(), $3, now()) + ON CONFLICT (restore_target_id) DO UPDATE SET + target_name = EXCLUDED.target_name, + last_checked_at = now(), + last_skip_reason = EXCLUDED.last_skip_reason, + updated_at = now()`, + [restoreTargetId, targetName, reason] + ); +} + +/** + * Avanza el cooldown. Se llama SOLO después de que el envío resolvió: si SMTP falla, el siguiente + * barrido tiene que volver a intentarlo. + */ +export async function markMonitorNotified(restoreTargetIds: number[]): Promise { + if (restoreTargetIds.length === 0) return; + await pgPool.query( + `UPDATE ${qMonitorState()} + SET alerted = TRUE, + notify_count = notify_count + 1, + last_notified_at = now(), + updated_at = now() + WHERE restore_target_id = ANY($1::int[])`, + [restoreTargetIds] + ); +} + +/** + * Cierra el incidente. Se llama al detectar la recuperación, **independientemente** de si el correo + * de "restablecido" se pudo enviar: dejar `alerted=true` en un servidor sano lo mostraría como + * incidente abierto y reintentaría ese correo en cada barrido para siempre. El costo —perder ese + * aviso si SMTP está caído justo en ese barrido— es menor y está documentado. + * + * `last_notified_at` se conserva como historia; ya no frena nada porque el cooldown solo se consulta + * cuando `alerted` es verdadero. + */ +export async function clearMonitorAlert(restoreTargetIds: number[]): Promise { + if (restoreTargetIds.length === 0) return; + await pgPool.query( + `UPDATE ${qMonitorState()} + SET alerted = FALSE, + notify_count = 0, + updated_at = now() + WHERE restore_target_id = ANY($1::int[])`, + [restoreTargetIds] + ); +} + +/** + * Borra el estado de destinos que ya no están en el inventario. Redundante cuando la FK se creó; + * necesario cuando no. + * + * Con la lista vacía no borra nada: un inventario vacío es muchísimo más probable que sea una + * consulta a medias que un panel al que le borraron todos los servidores. + */ +export async function pruneMonitorStates(knownTargetIds: number[]): Promise { + if (knownTargetIds.length === 0) return 0; + const r = await pgPool.query( + `DELETE FROM ${qMonitorState()} WHERE NOT (restore_target_id = ANY($1::int[]))`, + [knownTargetIds] + ); + return r.rowCount ?? 0; +} + +/** + * Corre `fn` con el advisory lock del barrido tomado; devuelve `'lock_busy'` si alguien más lo + * tiene. + * + * Hoy el panel corre en un solo proceso, así que la carrera real que esto evita no son las réplicas + * sino el **barrido manual disparado mientras el timer barre**: dos sesiones SSH al mismo servidor y + * dos decisiones sobre la misma fila. Si algún día se escala el servicio, ya está cubierto. + * + * Toma un cliente dedicado del pool porque el lock es por sesión y `pgPool` reparte conexiones. + */ +export async function withSweepLock(fn: () => Promise): Promise { + const client = await pgPool.connect(); + try { + const r = await client.query('SELECT pg_try_advisory_lock($1) AS locked', [SWEEP_LOCK_KEY]); + if (r.rows[0]?.locked !== true) return 'lock_busy'; + try { + return await fn(); + } finally { + // Un fallo al liberar no debe tapar el resultado de `fn`: la sesión se devuelve al pool + // y el lock muere con ella si la conexión se rompió. + try { + await client.query('SELECT pg_advisory_unlock($1)', [SWEEP_LOCK_KEY]); + } catch (err) { + logger.warn({ + message: 'No se pudo liberar el advisory lock del barrido de CRAS', + context: { error: err instanceof Error ? err.message : String(err) } + }); + } + } + } finally { + client.release(); + } +} diff --git a/src/lib/server/cras-monitor.ts b/src/lib/server/cras-monitor.ts new file mode 100644 index 0000000..146a495 --- /dev/null +++ b/src/lib/server/cras-monitor.ts @@ -0,0 +1,572 @@ +/** + * Monitor periódico de servidores de restauración: corre la misma sonda que el botón "Verificar" + * de /versiones-cras sobre toda la flota y avisa por correo cuando un servidor deja de dar + * servicio, con un segundo correo cuando vuelve. + * + * Nace de que `cloudrestore_status.reported_at` no es un heartbeat: el agente reporta en tres + * momentos de ciclo de vida y ninguno es periódico, así que sin esto nadie se entera de una caída + * hasta que alguien entra al panel y aprieta el botón a mano. + * + * El scheduler es in-process y sin dependencias: un `setTimeout` re-armado en el `finally` del tick, + * así el periodo se cuenta desde que TERMINA el barrido y es estructuralmente imposible que se + * acumulen corridas (el equivalente del `expires` de Celery Beat que usa el monitor de respaldos de + * a24c). + * + * **Solo lee de los servidores.** No instala, no reinicia y no arranca agentes: el correo trae el + * remedio y el enlace, y la decisión de aplicarlo es de una persona. + */ +import { randomUUID } from 'node:crypto'; + +import { verifyCrasTarget, type VerifyResult } from './cras-verify'; +import { listCrasTargetInventory, type CrasTargetInventory } from './cras-releases'; +import { isSmtpConfigured, sendSmtpEmail } from './email-service'; +import { logger } from './logger'; +import { configProblems, monitorConfig, type CrasMonitorConfig } from './cras-monitor-config'; +import { + decideMonitorAction, + emptyReasonCounters, + mapWithConcurrency, + withTimeout, + type MonitorEmailKind, + type MonitorReason, + type MonitorSeverity, + type MonitorSkipReason, + type MonitorStateRow +} from './cras-monitor-core'; +import { + buildDownDigestEmail, + buildRecoveredDigestEmail, + toEmailItem, + type MonitorEmailItem +} from './cras-monitor-email'; +import { + clearMonitorAlert, + ensureCrasMonitorSchema, + listMonitorStates, + markMonitorNotified, + pruneMonitorStates, + saveMonitorObservation, + saveMonitorSkip, + withSweepLock +} from './cras-monitor-state'; + +export interface SweepOptions { + trigger?: 'timer' | 'manual'; + /** Sondea y decide, pero no escribe en la base ni manda correo. */ + dryRun?: boolean; + traceId?: string; +} + +export interface SweepItem { + restore_target_id: number; + name: string; + severity: MonitorSeverity; + /** Null en los destinos omitidos: no hubo veredicto. */ + diagnosis: string | null; + reason: MonitorReason; + /** Se agotó el tope por destino antes de que la sonda terminara. */ + timed_out: boolean; + duration_ms: number; +} + +export interface SweepReport { + trace_id: string; + trigger: 'timer' | 'manual'; + dry_run: boolean; + /** Por qué no se hizo nada, cuando no se hizo nada. */ + skipped: 'disabled' | 'in_progress' | 'lock_busy' | 'config' | null; + started_at: string; + duration_ms: number; + targets_total: number; + targets_checked: number; + counters: Record; + down_notified: number; + recovered_notified: number; + /** Mensaje del fallo de SMTP, si hubo. El cooldown NO avanza cuando esto viene poblado. */ + email_error: string | null; + items: SweepItem[]; +} + +export interface MonitorRuntimeStatus { + enabled: boolean; + running: boolean; + scheduled: boolean; + sweep_count: number; + last_started_at: string | null; + last_finished_at: string | null; + last_duration_ms: number | null; + last_error: string | null; + config: { + interval_minutes: number; + concurrency: number; + failure_threshold: number; + cooldown_hours: number; + notify_config_issues: boolean; + /** Cuántos, no cuáles: las direcciones no se exponen por HTTP. */ + recipients: number; + ignored_target_ids: number[]; + }; + problems: string[]; +} + +// ============================================================================ +// Barrido +// ============================================================================ + +/** Resultado sintético para los dos casos en que la sonda no entrega un veredicto propio. */ +function syntheticResult(row: CrasTargetInventory, summary: string): VerifyResult { + return { + restore_target_id: row.restore_target_id, + name: row.name, + platform: row.platform, + diagnosis: 'error', + summary, + remediation: null, + checks: [], + checked_at: new Date().toISOString() + }; +} + +/** Qué omitir y por qué, con el orden de precedencia que le sirve al operador. */ +function skipReasonFor( + row: CrasTargetInventory, + ignoredTargetIds: readonly number[] +): MonitorSkipReason | null { + if (ignoredTargetIds.includes(row.restore_target_id)) return 'omitido_ignorado'; + // Durante una instalación el instalador detiene el agente: sondear ahí produce un + // `servicio_detenido` que no es una caída sino el proceso normal de actualizar. + if (row.running_install_id !== null) return 'omitido_instalacion'; + if (!row.has_ssh_credentials) return 'omitido_sin_credenciales'; + return null; +} + +interface ProbeOutcome { + row: CrasTargetInventory; + result: VerifyResult; + timedOut: boolean; + durationMs: number; +} + +/** + * Sonda un destino sin poder fallar. + * + * `verifyCrasTarget` documenta que no lanza por un fallo del destino, pero sí lo hace: `execRemote` + * rechaza con 504 al vencer su timeout y ni `inspectLinux` ni `inspectWindows` lo capturan. Sin este + * try/catch, un servidor alcanzable pero lento tumbaría el barrido completo y no saldría **ningún** + * correo — el modo de falla más caro posible para esta feature, porque se manifiesta justo cuando + * hay algo que reportar. + */ +async function probeTarget(row: CrasTargetInventory, cfg: CrasMonitorConfig): Promise { + const startedAt = Date.now(); + let timedOut = false; + let result: VerifyResult; + try { + result = await withTimeout(verifyCrasTarget(row.restore_target_id), cfg.targetTimeoutMs, () => { + timedOut = true; + return syntheticResult( + row, + `${row.name}: la verificación no terminó en ${Math.round(cfg.targetTimeoutMs / 1000)}s y se abandonó.` + ); + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + result = syntheticResult(row, `${row.name}: la verificación falló — ${message}`); + } + return { row, result, timedOut, durationMs: Date.now() - startedAt }; +} + +let running = false; +let sweepCount = 0; +let lastStartedAt: string | null = null; +let lastFinishedAt: string | null = null; +let lastDurationMs: number | null = null; +let lastError: string | null = null; + +export async function runCrasMonitorSweep(options: SweepOptions = {}): Promise { + const trigger = options.trigger ?? 'manual'; + const dryRun = options.dryRun === true; + const traceId = options.traceId ?? randomUUID(); + const cfg = monitorConfig(); + const startedAtMs = Date.now(); + const startedAt = new Date(startedAtMs).toISOString(); + + const base: SweepReport = { + trace_id: traceId, + trigger, + dry_run: dryRun, + skipped: null, + started_at: startedAt, + duration_ms: 0, + targets_total: 0, + targets_checked: 0, + counters: emptyReasonCounters(), + down_notified: 0, + recovered_notified: 0, + email_error: null, + items: [] + }; + const finish = (report: SweepReport): SweepReport => ({ + ...report, + duration_ms: Date.now() - startedAtMs + }); + + // El disparo manual corre aunque el kill-switch esté apagado: es justamente la vía para probar + // la clasificación en producción sin prender nada. + if (!cfg.enabled && trigger === 'timer') { + return finish({ ...base, skipped: 'disabled' }); + } + // Guard en memoria: barato y funciona aunque PostgreSQL esté caído. + if (running) { + return finish({ ...base, skipped: 'in_progress' }); + } + + running = true; + lastStartedAt = startedAt; + try { + const outcome = await withSweepLock(() => sweep(cfg, base, dryRun, traceId)); + if (outcome === 'lock_busy') { + return finish({ ...base, skipped: 'lock_busy' }); + } + lastError = null; + return finish(outcome); + } catch (err) { + lastError = err instanceof Error ? err.message : String(err); + throw err; + } finally { + running = false; + sweepCount += 1; + lastFinishedAt = new Date().toISOString(); + lastDurationMs = Date.now() - startedAtMs; + } +} + +async function sweep( + cfg: CrasMonitorConfig, + base: SweepReport, + dryRun: boolean, + traceId: string +): Promise { + await ensureCrasMonitorSchema(); + + const inventory = await listCrasTargetInventory(); + const states = new Map( + (await listMonitorStates()).map((row) => [row.restore_target_id, row]) + ); + + const counters = emptyReasonCounters(); + const items: SweepItem[] = []; + const toProbe: CrasTargetInventory[] = []; + const skipped: { row: CrasTargetInventory; reason: MonitorSkipReason }[] = []; + + for (const row of inventory) { + const reason = skipReasonFor(row, cfg.ignoredTargetIds); + if (reason) skipped.push({ row, reason }); + else toProbe.push(row); + } + + const outcomes = await mapWithConcurrency(toProbe, cfg.concurrency, (row) => + probeTarget(row, cfg) + ); + + // Segunda lectura del inventario, una sola para todo el barrido: un destino al que le empezó + // una instalación mientras se le sondeaba se reclasifica como omitido en lugar de reportarse + // como caído. El instalador detiene el agente, así que el veredicto sería un falso positivo. + const installingNow = new Set( + (await listCrasTargetInventory()) + .filter((row) => row.running_install_id !== null) + .map((row) => row.restore_target_id) + ); + + for (const { row, reason } of skipped) { + counters[reason] += 1; + items.push({ + restore_target_id: row.restore_target_id, + name: row.name, + severity: states.get(row.restore_target_id)?.severity ?? 'sin_evaluar', + diagnosis: null, + reason, + timed_out: false, + duration_ms: 0 + }); + if (!dryRun) await saveMonitorSkip(row.restore_target_id, row.name, reason); + } + + const now = new Date(); + const toNotify: Record = { caido: [], restablecido: [] }; + const clearIds: number[] = []; + + for (const { row, result, timedOut, durationMs } of outcomes) { + if (installingNow.has(row.restore_target_id)) { + counters.omitido_instalacion += 1; + items.push({ + restore_target_id: row.restore_target_id, + name: row.name, + severity: states.get(row.restore_target_id)?.severity ?? 'sin_evaluar', + diagnosis: null, + reason: 'omitido_instalacion', + timed_out: timedOut, + duration_ms: durationMs + }); + if (!dryRun) { + await saveMonitorSkip(row.restore_target_id, row.name, 'omitido_instalacion'); + } + continue; + } + + const decision = decideMonitorAction({ + previous: states.get(row.restore_target_id) ?? null, + diagnosis: result.diagnosis, + summary: result.summary, + now, + failureThreshold: cfg.failureThreshold, + cooldownMs: cfg.cooldownMs, + notifyConfigIssues: cfg.notifyConfigIssues + }); + + counters[decision.reason] += 1; + items.push({ + restore_target_id: row.restore_target_id, + name: row.name, + severity: decision.severity, + diagnosis: result.diagnosis, + reason: decision.reason, + timed_out: timedOut, + duration_ms: durationMs + }); + + if (!dryRun) { + await saveMonitorObservation(row.restore_target_id, row.name, decision.observation); + } + if (decision.observation.clearAlert) clearIds.push(row.restore_target_id); + + if (decision.email) { + toNotify[decision.email].push( + toEmailItem(result, decision.observation.consecutiveFailures, decision.severity, [ + row.ssh_host, + row.ssh_username, + row.server_ip + ]) + ); + } + } + + // El incidente se cierra al detectarlo, sin esperar al correo: ver clearMonitorAlert. + if (!dryRun) await clearMonitorAlert(clearIds); + + let emailError: string | null = null; + let downNotified = 0; + let recoveredNotified = 0; + + if (toNotify.caido.length > 0) { + const sent = await deliverDigest('caido', toNotify.caido, cfg, dryRun, traceId); + if (sent.ok) { + downNotified = toNotify.caido.length; + if (!dryRun) await markMonitorNotified(toNotify.caido.map((i) => i.restore_target_id)); + } else { + emailError = sent.error; + } + } + if (toNotify.restablecido.length > 0) { + const sent = await deliverDigest('restablecido', toNotify.restablecido, cfg, dryRun, traceId); + if (sent.ok) recoveredNotified = toNotify.restablecido.length; + else emailError = emailError ?? sent.error; + } + + if (!dryRun) { + const pruned = await pruneMonitorStates(inventory.map((row) => row.restore_target_id)); + if (pruned > 0) { + logger.info({ + trace_id: traceId, + message: 'Estado del monitor de CRAS depurado', + context: { rows: pruned } + }); + } + } + + return { + ...base, + counters, + items, + targets_total: inventory.length, + targets_checked: outcomes.length, + down_notified: downNotified, + recovered_notified: recoveredNotified, + email_error: emailError + }; +} + +async function deliverDigest( + kind: MonitorEmailKind, + items: MonitorEmailItem[], + cfg: CrasMonitorConfig, + dryRun: boolean, + traceId: string +): Promise<{ ok: true } | { ok: false; error: string }> { + if (dryRun) return { ok: true }; + const rendered = + kind === 'caido' + ? buildDownDigestEmail(items, cfg.panelUrl) + : buildRecoveredDigestEmail(items, cfg.panelUrl); + try { + await sendSmtpEmail({ + toAddrs: cfg.recipients, + subject: rendered.subject, + plainBody: rendered.plainBody, + htmlBody: rendered.htmlBody, + fromDisplayName: 'Panel Aduanasoft — Monitor de restauradores', + highImportance: kind === 'caido' + }); + logger.info({ + trace_id: traceId, + message: 'Aviso del monitor de CRAS enviado', + context: { kind, targets: items.map((i) => i.name), recipients: cfg.recipients.length } + }); + return { ok: true }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + logger.error({ + trace_id: traceId, + message: 'No se pudo enviar el aviso del monitor de CRAS', + context: { kind, targets: items.map((i) => i.name), error: message } + }); + return { ok: false, error: message }; + } +} + +// ============================================================================ +// Scheduler +// ============================================================================ + +let timer: ReturnType | null = null; +let stopped = false; + +export function startCrasMonitor(): void { + const cfg = monitorConfig(); + if (!cfg.enabled) { + logger.info({ + message: 'Monitor de CRAS deshabilitado', + context: { hint: 'CRAS_MONITOR_ENABLED=true para activarlo' } + }); + return; + } + + const problems = configProblems(cfg, isSmtpConfigured()); + if (problems.length > 0) { + // No se programa a propósito. Un monitor que sondea pero no puede avisar es peor que no + // tener monitor: da una falsa sensación de vigilancia mientras el incidente pasa inadvertido. + logger.error({ + message: 'Monitor de CRAS habilitado pero no puede notificar; no se programa', + context: { problems } + }); + return; + } + + if (process.env.NODE_ENV !== 'production') { + logger.warn({ + message: 'Monitor de CRAS activo fuera de producción: hará SSH a los servidores registrados', + context: { node_env: process.env.NODE_ENV ?? null } + }); + } + + stopped = false; + logger.info({ + message: 'Monitor de CRAS programado', + context: { + interval_minutes: cfg.intervalMs / 60_000, + initial_delay_ms: cfg.initialDelayMs, + concurrency: cfg.concurrency, + failure_threshold: cfg.failureThreshold, + cooldown_hours: cfg.cooldownMs / 3_600_000, + notify_config_issues: cfg.notifyConfigIssues, + // El conteo y no las direcciones: los logs se recolectan y se comparten. + recipients: cfg.recipients.length, + ignored_target_ids: cfg.ignoredTargetIds + } + }); + schedule(cfg.initialDelayMs); +} + +export function stopCrasMonitor(): void { + stopped = true; + if (timer) { + clearTimeout(timer); + timer = null; + } +} + +function schedule(delayMs: number): void { + if (stopped) return; + timer = setTimeout(tick, delayMs); + // Un timer suelto no debe ser la razón por la que el proceso no termina; el servidor HTTPS es + // el que mantiene vivo el event loop. + timer.unref?.(); +} + +async function tick(): Promise { + const cfg = monitorConfig(); + try { + const report = await runCrasMonitorSweep({ trigger: 'timer' }); + if (report.skipped) { + logger.info({ + trace_id: report.trace_id, + message: 'Barrido del monitor de CRAS omitido', + context: { skipped: report.skipped } + }); + } else { + logger.info({ + trace_id: report.trace_id, + message: 'Barrido del monitor de CRAS terminado', + context: { + duration_ms: report.duration_ms, + targets_total: report.targets_total, + targets_checked: report.targets_checked, + down_notified: report.down_notified, + recovered_notified: report.recovered_notified, + email_error: report.email_error, + counters: report.counters + } + }); + if (report.duration_ms > cfg.intervalMs) { + // La cadencia efectiva se estira en silencio y con ella la latencia de detección. + logger.warn({ + trace_id: report.trace_id, + message: 'El barrido del monitor de CRAS tardó más que su intervalo', + context: { duration_ms: report.duration_ms, interval_ms: cfg.intervalMs } + }); + } + } + } catch (err) { + // `runCrasMonitorSweep` ya captura por destino; esto cubre lo de más arriba: PostgreSQL + // caído al leer el inventario o al aplicar el DDL. + logger.error({ + message: 'El barrido del monitor de CRAS falló', + context: { error: err instanceof Error ? err.message : String(err) } + }); + } finally { + schedule(cfg.intervalMs); + } +} + +export function crasMonitorStatus(): MonitorRuntimeStatus { + const cfg = monitorConfig(); + return { + enabled: cfg.enabled, + running, + scheduled: timer !== null && !stopped, + sweep_count: sweepCount, + last_started_at: lastStartedAt, + last_finished_at: lastFinishedAt, + last_duration_ms: lastDurationMs, + last_error: lastError, + config: { + interval_minutes: cfg.intervalMs / 60_000, + concurrency: cfg.concurrency, + failure_threshold: cfg.failureThreshold, + cooldown_hours: cfg.cooldownMs / 3_600_000, + notify_config_issues: cfg.notifyConfigIssues, + recipients: cfg.recipients.length, + ignored_target_ids: cfg.ignoredTargetIds + }, + problems: configProblems(cfg, isSmtpConfigured()) + }; +} diff --git a/src/lib/server/cras-releases.ts b/src/lib/server/cras-releases.ts index badf4c0..476c548 100644 --- a/src/lib/server/cras-releases.ts +++ b/src/lib/server/cras-releases.ts @@ -37,7 +37,8 @@ function qCloudRestoreStatus(): string { return `"${SCHEMA}"."cloudrestore_status"`; } -function isPgUndefinedTable(err: unknown): boolean { +/** True si el error es "la tabla no existe" (42P01). Lo usa el DDL de respaldo del monitor. */ +export function isPgUndefinedTable(err: unknown): boolean { return typeof err === 'object' && err !== null && (err as { code?: string }).code === '42P01'; } diff --git a/src/lib/server/db-move.ts b/src/lib/server/db-move.ts index 7e58a05..bf5eb6e 100644 --- a/src/lib/server/db-move.ts +++ b/src/lib/server/db-move.ts @@ -12,6 +12,7 @@ import path from 'node:path'; import fs from 'node:fs/promises'; import { env } from '$env/dynamic/private'; import { + ADMIN_TRIGGERED, getMssqlPoolMaster, resolveNodeSqlPassword, queryDatabaseMetricsOnServer, @@ -79,13 +80,20 @@ export async function moveDatabaseToNewServer( throw new Error('El servidor viejo no tiene credenciales SSH configuradas.'); } - const oldPool = await getMssqlPoolMaster(oldTarget.server_ip, oldTarget.sql_password, oldTarget.sql_username); + const oldPool = await getMssqlPoolMaster( + oldTarget.server_ip, + oldTarget.sql_password, + oldTarget.sql_username, + undefined, + ADMIN_TRIGGERED + ); // 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() + ddlTimeoutMs(), + ADMIN_TRIGGERED ); const oldDbs = await listUserDatabasesOnServer(oldPool); const oldInfo = oldDbs.find((d) => d.name.toLowerCase() === dbName.trim().toLowerCase()); @@ -114,7 +122,13 @@ export async function moveDatabaseToNewServer( const tolerance = sizeTolerance(); const newServer = String(node.ServerName || '').trim(); - const newPool = await getMssqlPoolMaster(newServer, resolveNodeSqlPassword(node.sql_password)); + const newPool = await getMssqlPoolMaster( + newServer, + resolveNodeSqlPassword(node.sql_password), + undefined, + undefined, + ADMIN_TRIGGERED + ); // Si ya existe en el nuevo, no re-enviamos (evita trabajo y sobrescrituras). const already = await queryDatabaseMetricsOnServer(newPool, realName); diff --git a/src/lib/server/dedup-databases.ts b/src/lib/server/dedup-databases.ts index 10e3927..3edb7e1 100644 --- a/src/lib/server/dedup-databases.ts +++ b/src/lib/server/dedup-databases.ts @@ -9,6 +9,7 @@ */ import { env } from '$env/dynamic/private'; import { + ADMIN_TRIGGERED, getMssqlPoolMaster, resolveNodeSqlPassword, queryDatabaseMetricsOnServer, @@ -196,7 +197,13 @@ export async function scanDuplicates(oldTargetId: number): Promise { const tolerance = sizeTolerance(); const oldHost = normalizeServerHost(target.server_ip); - const oldPool = await getMssqlPoolMaster(target.server_ip, target.sql_password, target.sql_username); + const oldPool = await getMssqlPoolMaster( + target.server_ip, + target.sql_password, + target.sql_username, + undefined, + ADMIN_TRIGGERED + ); 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). @@ -256,13 +263,20 @@ export async function dropDuplicates( const tolerance = sizeTolerance(); const oldHost = normalizeServerHost(target.server_ip); - const oldPool = await getMssqlPoolMaster(target.server_ip, target.sql_password, target.sql_username); + const oldPool = await getMssqlPoolMaster( + target.server_ip, + target.sql_password, + target.sql_username, + undefined, + ADMIN_TRIGGERED + ); // 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() + ddlTimeoutMs(), + ADMIN_TRIGGERED ); const oldDbs = await listUserDatabasesOnServer(oldPool); const oldByName = new Map(oldDbs.map((d) => [d.name.toLowerCase(), d])); diff --git a/src/lib/server/email-service.ts b/src/lib/server/email-service.ts index 6689821..38a36f5 100644 --- a/src/lib/server/email-service.ts +++ b/src/lib/server/email-service.ts @@ -31,6 +31,20 @@ function buildTransporter() { }); } +/** + * ¿Hay configuración suficiente para que `sendSmtpEmail` realmente entregue algo? + * + * `sendSmtpEmail` calla y retorna cuando falta el host o el remitente, y para un botón que el + * operador aprieta eso está bien: ve que no pasó nada y lo reporta. Para un proceso automático es + * una trampa, porque interpretaría "resolvió" como "se envió" y avanzaría su cooldown sin haber + * avisado: quedaría mudo para siempre. Quien envíe sin supervisión debe consultar esto antes. + */ +export function isSmtpConfigured(): boolean { + const host = (process.env.SMTP_HOST ?? '').trim(); + const fromAddr = (process.env.SMTP_FROM || process.env.SMTP_USER || '').trim(); + return Boolean(host && fromAddr); +} + export async function sendSmtpEmail(opts: SendEmailOptions): Promise { const host = (process.env.SMTP_HOST ?? '').trim(); if (!host || opts.toAddrs.length === 0) return; @@ -82,8 +96,14 @@ const FOOTER = `
` + `© 2024 TransmitirAS. Todos los derechos reservados.
`; -/** Envuelve el contenido en el mismo cascarón del legacy (Arial, logo centrado, pie). */ -function renderSyncEmail(inner: string): string { +/** + * Envuelve el contenido en el mismo cascarón del legacy (Arial, logo centrado, pie). + * + * Se exporta para que otros avisos del panel —p. ej. el monitor de servidores de restauración— + * reusen el cascarón en lugar de copiarlo: dos copias de la plantilla divergen en el primer + * cambio de marca y el operador acaba recibiendo correos que no parecen del mismo sistema. + */ +export function renderBrandEmail(inner: string): string { return ` @@ -131,7 +151,7 @@ export function buildBackupAlertHtml(alerts: BackupAlertRow[]): string {

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

${SCAIIWEB_H4}

Por favor, recuerde nunca cerrar la aplicación ni apagar su equipo. Revise la conexión e intente realizar una sincronización manual desde el botón Backup manual, o contacte al soporte técnico si es necesario.

`; - return renderSyncEmail(inner); + return renderBrandEmail(inner); } /** Correo de "sincronización restablecida" (kind=resolved): misma plantilla legacy, mensaje positivo. */ @@ -147,7 +167,7 @@ export function buildBackupResolvedHtml(alerts: BackupAlertRow[]): string {

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

${SCAIIWEB_H4}

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

`; - return renderSyncEmail(inner); + return renderBrandEmail(inner); } export interface BackupAlertRow { @@ -157,6 +177,7 @@ export interface BackupAlertRow { daysWithout: number | null; } -function escHtml(s: string): string { +/** Escape de HTML para interpolar texto de la base en las plantillas. Compartido con el monitor. */ +export function escHtml(s: string): string { return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } diff --git a/src/lib/server/mssql-breaker.test.ts b/src/lib/server/mssql-breaker.test.ts new file mode 100644 index 0000000..11d492a --- /dev/null +++ b/src/lib/server/mssql-breaker.test.ts @@ -0,0 +1,234 @@ +/** + * Pruebas del corta-circuitos por servidor SQL Server. Lo caro de equivocarse aquí no es fallar + * rápido, es (a) dejar pasar más de una sonda cuando vence el enfriamiento —que es justo lo que + * inundó el firewall del destino— y (b) quedarse abierto para siempre, que dejaría el dashboard sin + * métricas aunque el servidor ya volvió. + * + * Reloj inyectado: nada de timers falsos ni esperas reales. + */ +import { describe, it, expect } from 'vitest'; +import { + createSqlServerBreaker, + SqlServerUnreachableError, + breakerKey, + type ServerBreaker +} from './mssql-breaker'; + +const COOLDOWN = 60_000; +const SERVER = '74.208.123.20,1433'; + +/** Corta-circuitos con reloj manual. `clock.at` avanza a mano en cada prueba. */ +function withClock(overrides: { cooldownMs?: number; probeTimeoutMs?: number; maxEntries?: number } = {}): { + breaker: ServerBreaker; + advance: (ms: number) => void; +} { + const clock = { at: 1_000_000 }; + const breaker = createSqlServerBreaker({ + cooldownMs: overrides.cooldownMs ?? COOLDOWN, + probeTimeoutMs: overrides.probeTimeoutMs, + maxEntries: overrides.maxEntries, + now: () => clock.at + }); + return { breaker, advance: (ms: number) => (clock.at += ms) }; +} + +describe('estado inicial', () => { + it('un servidor del que no se sabe nada se puede usar', () => { + const { breaker } = withClock(); + expect(() => breaker.assertUsable(SERVER)).not.toThrow(); + expect(breaker.stateOf(SERVER)).toBe('cerrado'); + expect(breaker.snapshot()).toEqual([]); + }); +}); + +describe('abrir al primer fallo', () => { + it('el primer fallo reporta transición y bloquea los intentos siguientes', () => { + const { breaker } = withClock(); + expect(breaker.noteFailure(SERVER, 'ETIMEOUT')).toBe(true); + expect(breaker.stateOf(SERVER)).toBe('abierto'); + expect(() => breaker.assertUsable(SERVER)).toThrow(SqlServerUnreachableError); + }); + + it('un fallo posterior NO reporta transición: el log lleva una línea por caída, no por base', () => { + const { breaker, advance } = withClock(); + expect(breaker.noteFailure(SERVER, 'ETIMEOUT')).toBe(true); + advance(COOLDOWN); + breaker.assertUsable(SERVER); // autoriza la sonda + expect(breaker.noteFailure(SERVER, 'ETIMEOUT')).toBe(false); + }); + + it('el error trae lo que la UI necesita: desde cuándo, cuántos fallos y cuánto falta', () => { + const { breaker, advance } = withClock(); + breaker.noteFailure(SERVER, 'Failed to connect in 5000ms'); + advance(20_000); + try { + breaker.assertUsable(SERVER); + throw new Error('debió lanzar'); + } catch (err) { + expect(err).toBeInstanceOf(SqlServerUnreachableError); + const e = err as SqlServerUnreachableError; + expect(e.address).toBe(SERVER); + expect(e.failures).toBe(1); + expect(e.lastError).toBe('Failed to connect in 5000ms'); + expect(e.retryAfterMs).toBe(COOLDOWN - 20_000); + expect(new Date(e.since).getTime()).toBeLessThan(new Date().getTime() + 1); + } + }); +}); + +describe('fallar rápido durante el enfriamiento', () => { + it('cien llamadas durante el enfriamiento no autorizan ni un intento', () => { + const { breaker, advance } = withClock(); + breaker.noteFailure(SERVER, 'ETIMEOUT'); + let bloqueadas = 0; + for (let i = 0; i < 100; i++) { + advance(500); // 50 s en total, por debajo del enfriamiento + try { + breaker.assertUsable(SERVER); + } catch { + bloqueadas += 1; + } + } + expect(bloqueadas).toBe(100); + }); +}); + +describe('medio-abierto: exactamente una sonda', () => { + it('vencido el enfriamiento pasa la primera y se bloquea la segunda', () => { + const { breaker, advance } = withClock(); + breaker.noteFailure(SERVER, 'ETIMEOUT'); + advance(COOLDOWN); + + expect(() => breaker.assertUsable(SERVER)).not.toThrow(); + expect(breaker.stateOf(SERVER)).toBe('medio_abierto'); + expect(() => breaker.assertUsable(SERVER)).toThrow(SqlServerUnreachableError); + expect(() => breaker.assertUsable(SERVER)).toThrow(SqlServerUnreachableError); + }); + + it('si la sonda falla, el enfriamiento se cuenta de nuevo desde ese fallo', () => { + const { breaker, advance } = withClock(); + breaker.noteFailure(SERVER, 'ETIMEOUT'); + advance(COOLDOWN); + breaker.assertUsable(SERVER); + breaker.noteFailure(SERVER, 'ETIMEOUT'); + + advance(COOLDOWN - 1); + expect(() => breaker.assertUsable(SERVER)).toThrow(SqlServerUnreachableError); + advance(1); + expect(() => breaker.assertUsable(SERVER)).not.toThrow(); + }); + + it('una sonda abandonada (nadie reportó) no deja el servidor bloqueado para siempre', () => { + const { breaker, advance } = withClock({ probeTimeoutMs: 10_000 }); + breaker.noteFailure(SERVER, 'ETIMEOUT'); + advance(COOLDOWN); + breaker.assertUsable(SERVER); // reserva la sonda y nunca reporta + + advance(9_999); + expect(() => breaker.assertUsable(SERVER)).toThrow(SqlServerUnreachableError); + advance(1); + expect(() => breaker.assertUsable(SERVER)).not.toThrow(); + }); +}); + +describe('cerrar al éxito', () => { + it('el éxito olvida el incidente y reporta transición', () => { + const { breaker, advance } = withClock(); + breaker.noteFailure(SERVER, 'ETIMEOUT'); + advance(COOLDOWN); + breaker.assertUsable(SERVER); + + expect(breaker.noteSuccess(SERVER)).toBe(true); + expect(breaker.stateOf(SERVER)).toBe('cerrado'); + expect(breaker.snapshot()).toEqual([]); + expect(() => breaker.assertUsable(SERVER)).not.toThrow(); + expect(() => breaker.assertUsable(SERVER)).not.toThrow(); + }); + + it('un éxito de un servidor que nunca falló no reporta transición', () => { + const { breaker } = withClock(); + expect(breaker.noteSuccess(SERVER)).toBe(false); + }); +}); + +describe('force: el disparo manual de un admin', () => { + it('no lo bloquea el enfriamiento', () => { + const { breaker } = withClock(); + breaker.noteFailure(SERVER, 'ETIMEOUT'); + expect(() => breaker.assertUsable(SERVER, { force: true })).not.toThrow(); + }); + + it('no consume la sonda del medio-abierto', () => { + const { breaker, advance } = withClock(); + breaker.noteFailure(SERVER, 'ETIMEOUT'); + breaker.assertUsable(SERVER, { force: true }); + advance(COOLDOWN); + // El force no reservó nada, así que el barrido automático sigue teniendo su intento. + expect(() => breaker.assertUsable(SERVER)).not.toThrow(); + }); +}); + +describe('identidad del servidor', () => { + it('la dirección se normaliza: caja y espacios no crean servidores distintos', () => { + const { breaker } = withClock(); + breaker.noteFailure(' HOST,1433 ', 'ETIMEOUT'); + expect(() => breaker.assertUsable('host,1433')).toThrow(SqlServerUnreachableError); + expect(breakerKey(' HOST,1433 ')).toBe('host,1433'); + }); + + it('un servidor caído no afecta a otro', () => { + const { breaker } = withClock(); + breaker.noteFailure(SERVER, 'ETIMEOUT'); + expect(() => breaker.assertUsable('10.0.0.9,1433')).not.toThrow(); + }); +}); + +describe('snapshot para la UI', () => { + it('lista los caídos ordenados, con desde/fallos/último error', () => { + const { breaker, advance } = withClock(); + breaker.noteFailure('zeta,1433', 'ETIMEOUT'); + breaker.noteFailure('alfa,1433', 'ECONNREFUSED'); + advance(15_000); + breaker.noteFailure('alfa,1433', 'ECONNREFUSED de nuevo'); + + const rows = breaker.snapshot(); + expect(rows.map((r) => r.address)).toEqual(['alfa,1433', 'zeta,1433']); + const alfa = rows[0]; + expect(alfa.failures).toBe(2); + expect(alfa.lastError).toBe('ECONNREFUSED de nuevo'); + // `since` conserva el PRIMER fallo, no el último: es la antigüedad del incidente. + expect(new Date(alfa.since).getTime()).toBe(new Date(rows[1].since).getTime()); + expect(alfa.retryAfterMs).toBe(COOLDOWN); + }); + + it('un motivo vacío no deja el error en blanco', () => { + const { breaker } = withClock(); + breaker.noteFailure(SERVER, ' '); + expect(breaker.snapshot()[0].lastError).toBe('sin detalle'); + }); +}); + +describe('límites y limpieza', () => { + it('no crece sin fin: desaloja el incidente más viejo', () => { + const { breaker, advance } = withClock({ maxEntries: 2 }); + breaker.noteFailure('viejo,1433', 'ETIMEOUT'); + advance(1_000); + breaker.noteFailure('medio,1433', 'ETIMEOUT'); + advance(1_000); + breaker.noteFailure('nuevo,1433', 'ETIMEOUT'); + + expect(breaker.snapshot().map((r) => r.address)).toEqual(['medio,1433', 'nuevo,1433']); + expect(() => breaker.assertUsable('viejo,1433')).not.toThrow(); + }); + + it('reset olvida uno o todos', () => { + const { breaker } = withClock(); + breaker.noteFailure('a,1433', 'ETIMEOUT'); + breaker.noteFailure('b,1433', 'ETIMEOUT'); + + breaker.reset('a,1433'); + expect(breaker.snapshot().map((r) => r.address)).toEqual(['b,1433']); + breaker.reset(); + expect(breaker.snapshot()).toEqual([]); + }); +}); diff --git a/src/lib/server/mssql-breaker.ts b/src/lib/server/mssql-breaker.ts new file mode 100644 index 0000000..702a46b --- /dev/null +++ b/src/lib/server/mssql-breaker.ts @@ -0,0 +1,224 @@ +/** + * Corta-circuitos por servidor SQL Server: recuerda qué servidor no contestó para no volver a + * abrirle un socket por cada base que tenga asignada. + * + * Nace de un incidente real: el dashboard sondea POR NODO (una fila por base) y la caché de pools + * borra la entrada al fallar la conexión, así que un servidor cuyo firewall TIRA los paquetes —sin + * RST, timeout completo de 5 s— costaba un intento por base, por barrido, y por pestaña abierta. + * El firewall del destino registró ~20 conexiones distintas por segundo, y de paso la carga se + * pasaba del tope del dashboard y lo dejaba en ceros. + * + * Aquí no hay PostgreSQL, ni `$env`, ni `mssql`, ni logger: es lógica pura con el reloj inyectado, + * para poder fijar las transiciones con pruebas sin mocks ni infraestructura (misma disciplina que + * `cras-monitor-core.ts`). Quien lo usa decide qué registrar, apoyándose en los valores de retorno + * de `noteFailure`/`noteSuccess`, que dicen si hubo TRANSICIÓN — así el log lleva una línea por + * servidor caído y no una por base. + */ + +/** Estado observable de un servidor. `medio_abierto` = venció el enfriamiento y hay una sonda en vuelo. */ +export type BreakerState = 'cerrado' | 'abierto' | 'medio_abierto'; + +export interface BreakerRow { + address: string; + state: BreakerState; + failures: number; + /** Cuándo empezó el incidente (ISO). Es lo que la UI muestra como "desde". */ + since: string; + lastError: string; + /** Milisegundos que faltan para permitir la próxima sonda. 0 si ya se permite. */ + retryAfterMs: number; +} + +/** + * Se lanza en lugar de intentar la conexión. Es un error de "no pregunté", que NO es lo mismo que + * "pregunté y falló": quien lo atrapa debe conservar el último valor conocido de esas bases en vez + * de borrarlas del dashboard. + */ +export class SqlServerUnreachableError extends Error { + readonly address: string; + readonly failures: number; + readonly since: string; + readonly retryAfterMs: number; + readonly lastError: string; + + constructor(row: BreakerRow) { + super( + `SQL Server ${row.address} marcado como inalcanzable desde ${row.since} ` + + `(${row.failures} fallo(s), último: ${row.lastError}). ` + + `No se intenta conectar por ${Math.ceil(row.retryAfterMs / 1000)}s más.` + ); + this.name = 'SqlServerUnreachableError'; + this.address = row.address; + this.failures = row.failures; + this.since = row.since; + this.retryAfterMs = row.retryAfterMs; + this.lastError = row.lastError; + } +} + +export interface BreakerOptions { + /** Tiempo mínimo entre sondas al mismo servidor caído. */ + cooldownMs: number; + /** + * Tope de vida de una sonda en vuelo. Sin esto, un llamador que muere sin reportar + * (una excepción fuera del try, un proceso ocupado) dejaría el servidor en `medio_abierto` + * para siempre y el corta-circuitos nunca volvería a cerrar. Por omisión, el enfriamiento. + */ + probeTimeoutMs?: number; + /** Reloj inyectable, en ms. Solo para pruebas; en producción es `Date.now`. */ + now?: () => number; + /** Tope de servidores recordados. Seguro contra crecimiento sin fin; se desaloja el más viejo. */ + maxEntries?: number; +} + +interface Entry { + failures: number; + firstFailureAt: number; + lastFailureAt: number; + lastError: string; + /** Marca de tiempo de la sonda autorizada en vuelo, o null si no hay ninguna. */ + probeStartedAt: number | null; +} + +const DEFAULT_MAX_ENTRIES = 256; + +/** Normaliza la dirección para que `HOST,1433` y `host,1433 ` sean el mismo servidor. */ +export function breakerKey(address: string): string { + return String(address ?? '') + .trim() + .toLowerCase(); +} + +export interface ServerBreaker { + /** + * Lanza `SqlServerUnreachableError` si al servidor no se le debe abrir un socket ahora mismo. + * Si devuelve normalmente, el llamador está autorizado a intentar la conexión y QUEDA OBLIGADO + * a reportar el desenlace con `noteSuccess` o `noteFailure`. + */ + assertUsable(address: string, opts?: { force?: boolean }): void; + /** Cierra el incidente. Devuelve true si venía de un estado caído (hubo transición). */ + noteSuccess(address: string): boolean; + /** Registra el fallo. Devuelve true si ESTE fallo abrió el corta-circuitos (hubo transición). */ + noteFailure(address: string, reason: string): boolean; + /** Estado de un servidor, sin tocar nada. */ + stateOf(address: string): BreakerState; + /** Todo lo que está caído, para pintarlo en la UI. */ + snapshot(): BreakerRow[]; + /** Olvida un servidor (o todos). Para pruebas y para el botón manual de un admin. */ + reset(address?: string): void; +} + +export function createSqlServerBreaker(options: BreakerOptions): ServerBreaker { + const cooldownMs = Math.max(0, options.cooldownMs); + const probeTimeoutMs = Math.max(0, options.probeTimeoutMs ?? cooldownMs); + const now = options.now ?? Date.now; + const maxEntries = Math.max(1, options.maxEntries ?? DEFAULT_MAX_ENTRIES); + const entries = new Map(); + + const rowOf = (address: string, entry: Entry, at: number): BreakerRow => ({ + address, + state: stateOfEntry(entry, at), + failures: entry.failures, + since: new Date(entry.firstFailureAt).toISOString(), + lastError: entry.lastError, + retryAfterMs: Math.max(0, entry.lastFailureAt + cooldownMs - at) + }); + + /** Una sonda cuenta como en vuelo solo si no se pasó de `probeTimeoutMs`. */ + const probeAlive = (entry: Entry, at: number): boolean => + entry.probeStartedAt !== null && at - entry.probeStartedAt < probeTimeoutMs; + + const stateOfEntry = (entry: Entry, at: number): BreakerState => { + if (probeAlive(entry, at)) return 'medio_abierto'; + return at - entry.lastFailureAt < cooldownMs ? 'abierto' : 'medio_abierto'; + }; + + const evictIfNeeded = (): void => { + while (entries.size > maxEntries) { + let oldestKey: string | null = null; + let oldestAt = Number.POSITIVE_INFINITY; + for (const [key, entry] of entries) { + if (entry.lastFailureAt < oldestAt) { + oldestAt = entry.lastFailureAt; + oldestKey = key; + } + } + if (oldestKey === null) return; + entries.delete(oldestKey); + } + }; + + return { + assertUsable(address, opts): void { + const key = breakerKey(address); + const entry = entries.get(key); + if (!entry) return; + + // El disparo manual de un admin (depuración, mover base) no debe esperar el + // enfriamiento: es una persona mirando la pantalla, no un barrido automático. Tampoco + // reserva la sonda, para no bloquear al barrido que venga después. + if (opts?.force) return; + + const at = now(); + // Ya hay alguien intentando: que este llamador no abra un segundo socket. Es lo que + // hace que "un solo intento al medio-abrir" sea una propiedad del corta-circuitos y no + // dependa de la deduplicación de promesas de quien lo usa. + if (probeAlive(entry, at)) throw new SqlServerUnreachableError(rowOf(key, entry, at)); + if (at - entry.lastFailureAt < cooldownMs) { + throw new SqlServerUnreachableError(rowOf(key, entry, at)); + } + // Venció el enfriamiento: se autoriza UNA sonda y se reserva hasta que reporte. + entry.probeStartedAt = at; + }, + + noteSuccess(address): boolean { + const key = breakerKey(address); + return entries.delete(key); + }, + + noteFailure(address, reason): boolean { + const key = breakerKey(address); + const at = now(); + const entry = entries.get(key); + const lastError = String(reason ?? '').trim() || 'sin detalle'; + if (!entry) { + entries.set(key, { + failures: 1, + firstFailureAt: at, + lastFailureAt: at, + lastError, + probeStartedAt: null + }); + evictIfNeeded(); + return true; + } + entry.failures += 1; + entry.lastFailureAt = at; + entry.lastError = lastError; + // La sonda reportó: se libera para que el próximo enfriamiento autorice otra. + entry.probeStartedAt = null; + return false; + }, + + stateOf(address): BreakerState { + const entry = entries.get(breakerKey(address)); + if (!entry) return 'cerrado'; + return stateOfEntry(entry, now()); + }, + + snapshot(): BreakerRow[] { + const at = now(); + return [...entries.entries()] + .map(([key, entry]) => rowOf(key, entry, at)) + .sort((a, b) => a.address.localeCompare(b.address)); + }, + + reset(address): void { + if (address === undefined) { + entries.clear(); + return; + } + entries.delete(breakerKey(address)); + } + }; +} diff --git a/src/lib/server/mssql-nodes.test.ts b/src/lib/server/mssql-nodes.test.ts index 7dc9bd6..21e1d6c 100644 --- a/src/lib/server/mssql-nodes.test.ts +++ b/src/lib/server/mssql-nodes.test.ts @@ -14,7 +14,15 @@ vi.mock('./crypto', async (importOriginal) => { }); import { decryptSecret } from './crypto'; -import { resolveNodeSqlPassword, parseMssqlServer } from './mssql-nodes'; +import { + resolveNodeSqlPassword, + parseMssqlServer, + sqlServerKey, + groupNodesByServer, + cloneDashboardBundle, + type CatalogNodeRow, + type SqlDashboardBundle +} from './mssql-nodes'; const decryptMock = vi.mocked(decryptSecret); @@ -84,3 +92,119 @@ describe('parseMssqlServer', () => { expect(parseMssqlServer(' 10.0.0.5 , abc ')).toEqual({ server: '10.0.0.5' }); }); }); + +describe('sqlServerKey', () => { + it('host y host,1433 son el MISMO servidor', () => { + // Es el invariante del corta-circuitos: si estas dos formas dieran llaves distintas, el + // mismo endpoint escrito de dos maneras en el catálogo abriría dos sockets. + expect(sqlServerKey('74.208.123.20')).toBe(sqlServerKey('74.208.123.20,1433')); + }); + + it('un puerto distinto es otro servidor', () => { + expect(sqlServerKey('10.0.0.5,1435')).not.toBe(sqlServerKey('10.0.0.5,1433')); + }); + + it('la instancia nombrada forma parte de la identidad', () => { + expect(sqlServerKey('HOST\\A')).not.toBe(sqlServerKey('HOST\\B')); + }); + + it('no distingue por caja ni por espacios', () => { + expect(sqlServerKey(' SQLSERVER01 , 1433 ')).toBe(sqlServerKey('sqlserver01')); + }); +}); + +describe('groupNodesByServer', () => { + const node = ( + id: number, + server: string, + bdName = `BD${id}`, + sqlPassword: string | null = null + ): CatalogNodeRow => + ({ + ID: id, + ServerName: server, + BDName: bdName, + NodoSubNodo: `NODO${id}`, + Nombre: `Cliente ${id}`, + sql_password: sqlPassword + }) as CatalogNodeRow; + + it('40 bases en 2 servidores dan 2 grupos, no 40', () => { + // El corazón del arreglo: antes se abría una conexión por BASE y con el firewall del destino + // tirando los paquetes eso costaba 5 s por base, por barrido y por pestaña abierta. + const nodes = [ + ...Array.from({ length: 25 }, (_, i) => node(i + 1, '74.208.123.20,1433')), + ...Array.from({ length: 15 }, (_, i) => node(100 + i, '10.0.0.9,1433')) + ]; + const groups = groupNodesByServer(nodes); + expect(groups).toHaveLength(2); + expect(groups.map((g) => g.buckets.flatMap((b) => b.nodes).length).sort((a, b) => a - b)).toEqual([ + 15, 25 + ]); + // Un solo balde de credenciales por servidor ⇒ un solo intento de conexión. + expect(groups.every((g) => g.buckets.length === 1)).toBe(true); + }); + + it('el mismo endpoint escrito de dos formas cae en UN grupo', () => { + const groups = groupNodesByServer([ + node(1, '74.208.123.20'), + node(2, '74.208.123.20,1433'), + node(3, ' 74.208.123.20 , 1433 ') + ]); + expect(groups).toHaveLength(1); + expect(groups[0].buckets[0].nodes.map((n) => n.ID)).toEqual([1, 2, 3]); + }); + + it('credenciales distintas en el mismo servidor: baldes separados, la mayoritaria primero', () => { + // Cada balde mide sus propias bases con su propia credencial: usar el pool de otra dejaría + // "no encontradas" a las bases cuyo login solo ve la suya. + const groups = groupNodesByServer([ + node(1, 'HOST01', 'BD1', 'PWD_RARA'), + node(2, 'HOST01', 'BD2', 'PWD_COMUN'), + node(3, 'HOST01', 'BD3', 'PWD_COMUN'), + node(4, 'HOST01', 'BD4', 'PWD_COMUN') + ]); + expect(groups).toHaveLength(1); + expect(groups[0].buckets.map((b) => b.nodes.length)).toEqual([3, 1]); + expect(groups[0].buckets[0].password).toBe('PWD_COMUN'); + }); + + it('un nodo sin nombre de base no se sondea', () => { + expect(groupNodesByServer([node(1, 'HOST01', ' ')])).toEqual([]); + }); +}); + +describe('cloneDashboardBundle', () => { + const bundle = (): SqlDashboardBundle => ({ + databaseRows: [{ visible_name: 'BD1', total_size_gb: 10, _node_id: 1 }], + summaryMain: { total_databases: 1, total_size_gb: 10 }, + alertsData: [{ visible_name: 'BD1', last_restore_date: null }], + restoreHistory: { bd1: [{ restore_date: new Date('2026-08-01T00:00:00Z') }] }, + effectivenessByDb: { bd1: [{ month: '2026-08', effectiveness: 50 }] }, + unreachableServers: [], + staleRows: 0, + unmeasuredRows: 0, + generatedAt: '2026-08-10T00:00:00.000Z' + }); + + it('mutar la copia no toca el original', () => { + // El barrido se comparte entre peticiones y el `load` MUTA summaryMain al recalcular totales + // para un usuario sin permisos de admin: sin la copia, ese ajuste se le quedaba pegado a + // todos los demás usuarios. + const original = bundle(); + const copy = cloneDashboardBundle(original); + + copy.summaryMain.total_size_gb = 999; + copy.summaryMain.total_databases = 0; + copy.databaseRows[0].total_size_gb = 999; + copy.databaseRows.push({ visible_name: 'INTRUSA' }); + copy.alertsData.length = 0; + copy.restoreHistory.bd1.push({ restore_date: new Date() }); + + expect(original.summaryMain).toEqual({ total_databases: 1, total_size_gb: 10 }); + expect(original.databaseRows).toHaveLength(1); + expect(original.databaseRows[0].total_size_gb).toBe(10); + expect(original.alertsData).toHaveLength(1); + expect(original.restoreHistory.bd1).toHaveLength(1); + }); +}); diff --git a/src/lib/server/mssql-nodes.ts b/src/lib/server/mssql-nodes.ts index 6a58560..2a9e0bc 100644 --- a/src/lib/server/mssql-nodes.ts +++ b/src/lib/server/mssql-nodes.ts @@ -5,19 +5,127 @@ import sql from 'mssql'; import { env } from '$env/dynamic/private'; import { decryptSecret, isEncrypted } from './crypto'; +import { logger } from './logger'; +import { + createSqlServerBreaker, + SqlServerUnreachableError, + type BreakerRow +} from './mssql-breaker'; +import { + listDatabaseNodesForMssql, + listNodeMetricsCache, + markNodeMetricsAttempt, + markNodeMetricsMissing, + pruneNodeMetricsCache, + upsertNodeMetrics, + type NodeMetricsCacheRow, + type NodeMetricsMeasurement +} from './controldesk-pg'; + +/** Entero de entorno acotado; un valor mal escrito cae al default en vez de a `NaN`. */ +function envInt(raw: string | undefined, fallback: number, min: number, max: number): number { + const text = String(raw ?? '').trim(); + if (!text) return fallback; + const value = Number(text); + if (!Number.isFinite(value)) return fallback; + return Math.min(max, Math.max(min, Math.trunc(value))); +} // Timeout de conexión acotado en TODOS los entornos: un SQL Server sano conecta en <1s, // así que 5s (alineado con el pool de PostgreSQL) es holgado. Antes eran 30s, lo que colgaba // la carga del dashboard cuando algún servidor no responde. Configurable por env si un // despliegue lo necesita. -const MSSQL_CONNECT_TIMEOUT_MS = Number(env.PANEL_MSSQL_CONNECT_TIMEOUT_MS) || 5000; +const MSSQL_CONNECT_TIMEOUT_MS = envInt(env.PANEL_MSSQL_CONNECT_TIMEOUT_MS, 5000, 500, 60_000); -const MAX_POOLS = 16; +/** + * Tope de consulta del dashboard. node-mssql usa 15 s por omisión, que es MÁS que el presupuesto + * de 12 s de la página: una sola consulta lenta contra un servidor sano bastaba para reventar la + * carga completa. Es constante a propósito —entra en la clave de caché de pools— para no fragmentar + * `poolMap` con un pool por cada valor distinto. + */ +const DASHBOARD_REQUEST_TIMEOUT_MS = envInt( + env.PANEL_MSSQL_DASHBOARD_REQUEST_TIMEOUT_MS, + 8000, + 1000, + 60_000 +); + +/** + * Cuánto se recuerda que un servidor no contestó. Es la garantía del arreglo: como máximo un intento + * de conexión por servidor por ventana, sin importar cuántas bases tenga asignadas, cuántas pestañas + * del dashboard estén abiertas ni cuántos endpoints pidan la carga. + */ +const MSSQL_DOWN_COOLDOWN_MS = envInt(env.PANEL_MSSQL_DOWN_COOLDOWN_MS, 60_000, 5_000, 3_600_000); + +const MAX_POOLS = envInt(env.PANEL_MSSQL_MAX_POOLS, 32, 4, 256); // Se cachea la *promesa* del pool (no el pool ya resuelto) para que varias cargas de nodos // concurrentes sobre el mismo servidor reutilicen una sola conexión en vuelo y no abran pools // duplicados (condición de carrera que aparece al paralelizar el dashboard). const poolMap = new Map>(); +/** + * Corta-circuitos compartido por TODOS los consumidores de `getMssqlPoolMaster` (dashboard, + * /api/dashboard.json, node-sizes, reportes/excel, db-move, depuración de duplicados). Vive a nivel + * de módulo, así que sobrevive ENTRE cargas: es justo lo que faltaba, porque la caché de pools se + * borra al fallar y cada carga volvía a intentarlo todo desde cero. + */ +const serverBreaker = createSqlServerBreaker({ cooldownMs: MSSQL_DOWN_COOLDOWN_MS }); + +/** Servidores marcados como inalcanzables ahora mismo. Para pintarlos y para diagnosticar. */ +export function listUnreachableSqlServers(): BreakerRow[] { + return serverBreaker.snapshot(); +} + +/** Olvida el estado de un servidor (o de todos). Para el reintento manual de un admin. */ +export function forgetSqlServerFailure(address?: string): void { + serverBreaker.reset(address ? sqlServerKey(address) : undefined); +} + +/** + * Marca para las operaciones que un admin dispara a mano —mover una base, depurar duplicados— que + * se saltan el enfriamiento del corta-circuitos: es una persona esperando frente a la pantalla, no + * un barrido automático, y son conexiones de un solo tiro. + * + * NO usarla en bucles por base: ahí es justo donde el corta-circuitos tiene que aplicar. + */ +export const ADMIN_TRIGGERED = { force: true } as const; + +/** + * Códigos de tedious que significan "no hubo con quién hablar". Solo estos abren el corta-circuitos: + * un `ELOGIN` prueba que el servidor SÍ contestó, y bloquear la dirección por una credencial + * equivocada dejaría sin métricas a las bases que sí tienen las suyas bien, además de disfrazar un + * problema de configuración como una caída de red. + */ +const UNREACHABLE_CODES = new Set([ + 'ETIMEOUT', + 'ESOCKET', + 'ECONNREFUSED', + 'ECONNRESET', + 'EHOSTUNREACH', + 'ENETUNREACH', + 'ENOTFOUND', + 'EAI_AGAIN' +]); + +function errorCodeOf(err: unknown): string { + if (typeof err !== 'object' || err === null) return ''; + const e = err as { code?: unknown; originalError?: { code?: unknown } }; + return String(e.code ?? e.originalError?.code ?? ''); +} + +function isUnreachableError(err: unknown): boolean { + if (err instanceof SqlServerUnreachableError) return true; + return UNREACHABLE_CODES.has(errorCodeOf(err)); +} + +/** Mensaje corto y sin secretos, para el log y para `last_error`. */ +export function describeSqlError(err: unknown): string { + const code = errorCodeOf(err); + const message = err instanceof Error ? err.message : String(err); + const text = code && !message.startsWith(code) ? `${code}: ${message}` : message; + return text.length > 300 ? `${text.slice(0, 297)}…` : text; +} + export function adjustMssqlServerForDocker(serverName: string): string { const docker = String(env.PANEL_MSSQL_DOCKER || '') @@ -62,6 +170,21 @@ export function parseMssqlServer(address: string): { return result; } +/** + * Identidad canónica de un endpoint SQL Server: `host[\instancia]:puerto`. + * + * Es la llave del corta-circuitos, y tiene que ser canónica: `74.208.123.20` y + * `74.208.123.20,1433` son el MISMO servidor, pero como texto son distintos. Sin esto, el mismo + * endpoint escrito de dos formas en el catálogo abría dos sockets y rompía justo la garantía que el + * corta-circuitos viene a dar. + */ +export function sqlServerKey(address: string): string { + const { server, port, instanceName } = parseMssqlServer(address); + const host = server.trim().toLowerCase(); + const instance = instanceName ? `\\${instanceName.trim().toLowerCase()}` : ''; + return `${host}${instance}:${port ?? 1433}`; +} + export function resolveMssqlUser(): string { return ( String(env.PANEL_MSSQL_USER || '').trim() @@ -93,17 +216,32 @@ function poolCacheKey(server: string, user: string, password: string, requestTim return `${server}\t${user}\t${password}\t${requestTimeoutMs ?? ''}`; } -async function evictPoolIfNeeded(): Promise { +/** + * Mueve la clave al final del Map para que el desalojo sea LRU y no FIFO. Sin esto, un acierto de + * caché no refrescaba el orden y se cerraba el pool del servidor más usado. + */ +function touchPool(key: string, entry: Promise): void { + if (poolMap.delete(key)) poolMap.set(key, entry); +} + +/** + * Hace lugar en la caché de pools. **Síncrona a propósito**: la versión anterior hacía + * `await` de la promesa del pool desalojado, y si ese pool todavía estaba conectando —el caso exacto + * de un servidor caído, 5 s colgado— el desalojo frenaba al que venía a ocupar el lugar. El cierre + * se agenda para cuando la promesa resuelva; si nunca resuelve, no hay nada que cerrar. + */ +function evictPoolIfNeeded(keep: string): void { while (poolMap.size >= MAX_POOLS) { - const first = poolMap.keys().next().value as string | undefined; - if (!first) break; - const old = poolMap.get(first); - poolMap.delete(first); - try { - (await old)?.close(); - } catch { - /* ignore */ - } + // El primero en orden de inserción es el menos usado recientemente (ver touchPool). + const oldest = [...poolMap.keys()].find((k) => k !== keep); + if (!oldest) break; + const old = poolMap.get(oldest); + poolMap.delete(oldest); + logger.warn({ + message: 'Se desalojó un pool de SQL Server por tope de pools', + context: { pools: poolMap.size, max: MAX_POOLS } + }); + void old?.then((pool) => pool.close()).catch(() => {}); } } @@ -136,7 +274,8 @@ export async function getMssqlPoolMaster( serverHost: string, password: string, userOverride?: string, - requestTimeoutMs?: number + requestTimeoutMs?: number, + opts?: { force?: boolean } ): 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. @@ -153,15 +292,33 @@ export async function getMssqlPoolMaster( const existing = poolMap.get(key); if (existing) { + // Reusar un pool cacheado NO abre un socket, así que se consulta antes del corta-circuitos: + // un servidor con conexión viva no debe quedar bloqueado por el enfriamiento de otra clave. try { const pool = await existing; - if (pool.connected || pool.connecting) return pool; - } catch { - /* pool inservible: se descarta y se recrea abajo */ + if (pool.connected || pool.connecting) { + touchPool(key, existing); + return pool; + } + // Pool rancio (ya cerrado): se descarta, pero solo si sigue siendo el registrado. + if (poolMap.get(key) === existing) poolMap.delete(key); + } catch (err) { + // El rechazo de la promesa compartida es el fallo de TODOS los que la esperaban. + // Antes se tragaba aquí y cada esperante abría SU PROPIO pool abajo: una ola de 8 nodos + // costaba 8 intentos extra de 5 s cada uno, en paralelo. Ese era el multiplicador que + // inundaba el firewall del destino. El estado del corta-circuitos ya lo registró quien + // creó la promesa; aquí solo se propaga. + if (poolMap.get(key) === existing) poolMap.delete(key); + throw err; } - poolMap.delete(key); } + // Sin conexión reutilizable: aquí sí se va a abrir un socket, así que manda el corta-circuitos. + // Se llavea por endpoint canónico y NO por el texto del catálogo: `host` y `host,1433` son el + // mismo servidor y deben compartir el mismo estado. + const serverKey = sqlServerKey(server); + serverBreaker.assertUsable(serverKey, opts); + // `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); @@ -182,6 +339,18 @@ export async function getMssqlPoolMaster( trustServerCertificate: true, connectTimeout: MSSQL_CONNECT_TIMEOUT_MS, ...(instanceName ? { instanceName } : {}) + }, + pool: { + // Menos sockets por servidor que el default de node-mssql (10): con 4 bases en vuelo y + // 3 consultas cada una, 4 conexiones encolan de sobra para consultas de catálogo. + max: 4, + // `min: 0` a propósito: con min>0, tarn intentaría reponer en segundo plano la conexión + // de un servidor que se cayó, fuera del control del corta-circuitos. + min: 0, + // Por encima del refresco del dashboard (40 s) y del TTL del barrido compartido. Con el + // default de 30 s la conexión moría entre pasadas y cada carga rehacía el handshake + // TLS contra todos los servidores, incluidos los sanos. + idleTimeoutMillis: 90_000 } }; @@ -189,15 +358,38 @@ export async function getMssqlPoolMaster( // cargas concurrentes al mismo servidor compartan esta conexión en vuelo y no creen pools // duplicados. La purga (evictPoolIfNeeded) ocurre dentro de la promesa, no antes de registrarla. const connecting = (async () => { - await evictPoolIfNeeded(); + evictPoolIfNeeded(key); return new sql.ConnectionPool(cfg).connect(); })(); poolMap.set(key, connecting); try { - return await connecting; + const pool = await connecting; + if (serverBreaker.noteSuccess(serverKey)) { + logger.info({ + message: 'SQL Server volvió a responder', + context: { server, server_key: serverKey } + }); + } + return pool; } catch (e) { - // No conservar en caché una conexión que falló al establecerse. - poolMap.delete(key); + // No conservar en caché una conexión que falló al establecerse; y solo borrar la propia, + // no una promesa más nueva que otro llamador ya registró. + if (poolMap.get(key) === connecting) poolMap.delete(key); + if (isUnreachableError(e)) { + // Una sola línea por servidor caído, no una por base: antes el log escupía una entrada + // por nodo en cada barrido y era imposible leerlo. + if (serverBreaker.noteFailure(serverKey, describeSqlError(e))) { + logger.warn({ + message: 'SQL Server inalcanzable: se deja de intentar por un rato', + context: { + server, + server_key: serverKey, + error: describeSqlError(e), + cooldown_ms: MSSQL_DOWN_COOLDOWN_MS + } + }); + } + } throw e; } } @@ -291,42 +483,42 @@ export async function queryRestoreHistoryForDatabase( return (result.recordset as any[]).map((r) => ({ restore_date: r.restore_date })); } -function computeEffectivenessFromHistory( - restoreHistory: Record -): Record { - const effectivenessByDb: Record = {}; +/** + * Efectividad mensual de UNA base a partir de su historial. Antes recibía el mapa completo; se + * partió por base para poder guardarla por nodo en la caché durable y reconstruirla sin volver a + * consultar SQL Server. La regla de meses es la de siempre (los tres del año en curso, recortados + * en enero), a propósito: no es el momento de cambiar el cálculo. + */ +export function computeEffectivenessForHistory( + history: { restore_date: Date | string }[] +): { month: string; effectiveness: number }[] { const now = new Date(); const currentYear = now.getFullYear(); const currentMonth = now.getMonth(); const monthsToInclude = [currentMonth, currentMonth - 1, currentMonth - 2].filter((m) => m >= 0); - for (const [dbName, history] of Object.entries(restoreHistory)) { - const monthly: { - [monthKey: string]: { daysWithRestore: Set; totalDays: number }; - } = {}; - for (const m of monthsToInclude) { - const monthKey = `${currentYear}-${String(m + 1).padStart(2, '0')}`; - monthly[monthKey] = { - daysWithRestore: new Set(), - totalDays: new Date(currentYear, m + 1, 0).getDate() - }; - } - for (const h of history) { - const d = new Date(h.restore_date); - const y = d.getFullYear(); - const m = d.getMonth(); - if (y !== currentYear || !monthsToInclude.includes(m)) continue; - const monthKey = `${y}-${String(m + 1).padStart(2, '0')}`; - const dayKey = d.toISOString().slice(0, 10); - monthly[monthKey]?.daysWithRestore.add(dayKey); - } - effectivenessByDb[dbName] = Object.entries(monthly).map(([month, data]) => { - const eff = - data.totalDays === 0 ? 0 : (data.daysWithRestore.size / data.totalDays) * 100; - return { month, effectiveness: Number(eff.toFixed(1)) }; - }); + const monthly: { [monthKey: string]: { daysWithRestore: Set; totalDays: number } } = {}; + for (const m of monthsToInclude) { + const monthKey = `${currentYear}-${String(m + 1).padStart(2, '0')}`; + monthly[monthKey] = { + daysWithRestore: new Set(), + totalDays: new Date(currentYear, m + 1, 0).getDate() + }; } - return effectivenessByDb; + for (const h of history) { + const d = new Date(h.restore_date); + if (!Number.isFinite(d.getTime())) continue; + const y = d.getFullYear(); + const m = d.getMonth(); + if (y !== currentYear || !monthsToInclude.includes(m)) continue; + const monthKey = `${y}-${String(m + 1).padStart(2, '0')}`; + const dayKey = d.toISOString().slice(0, 10); + monthly[monthKey]?.daysWithRestore.add(dayKey); + } + return Object.entries(monthly).map(([month, data]) => { + const eff = data.totalDays === 0 ? 0 : (data.daysWithRestore.size / data.totalDays) * 100; + return { month, effectiveness: Number(eff.toFixed(1)) }; + }); } export type ServerDatabaseInfo = { @@ -431,98 +623,487 @@ export async function backupDatabaseOnServer( `); } +/** Un servidor al que no se le pudo preguntar en este barrido, con las bases que se quedaron sin medir. */ +export type UnreachableSqlServer = { + server: string; + reason: string; + /** Desde cuándo está así según el corta-circuitos (ISO), o el momento de este fallo. */ + since: string; + affected_databases: number; +}; + export type SqlDashboardBundle = { databaseRows: any[]; summaryMain: { total_databases: number; total_size_gb: number }; alertsData: any[]; restoreHistory: Record; effectivenessByDb: Record; + /** Servidores que no contestaron. Vacío = la foto está completa y fresca. */ + unreachableServers: UnreachableSqlServer[]; + /** Cuántas filas salieron de la caché durable porque su servidor no contestó. */ + staleRows: number; + /** Cuántas bases no se pudieron medir Y tampoco tenían valor previo. */ + unmeasuredRows: number; + /** Cuándo se armó esta foto (ISO). La UI la usa para decir qué tan vieja es. */ + generatedAt: string; }; +/** Bases que comparten servidor y credencial: un solo pool las atiende a todas. */ +type PasswordBucket = { password: string; nodes: CatalogNodeRow[] }; +/** Un servidor del catálogo, con sus bases repartidas por credencial. */ +type ServerGroup = { address: string; serverKey: string; buckets: PasswordBucket[] }; + /** - * Recorre nodos activos del catálogo y consulta SQL Server en server_name (BD = database_name). + * Agrupa los nodos por SERVIDOR y, dentro, por credencial. + * + * Antes el barrido iteraba nodos, y como el catálogo viene ordenado por `node_subnode_key` + * (controldesk-pg.ts, `listDatabaseNodesForMssql`), las bases de un mismo servidor quedan + * intercaladas: con 8 nodos en vuelo casi nunca coincidían dos del mismo servidor, así que la + * promesa compartida del pool no ayudaba y se pagaba un `connect()` por base. + * + * Los baldes se ordenan por número de bases: se prueba primero la credencial mayoritaria, que es la + * que tiene más probabilidad de ser la buena si alguna quedó mal capturada en un nodo suelto. + */ +export function groupNodesByServer(nodes: CatalogNodeRow[]): ServerGroup[] { + const byServer = new Map(); + for (const node of nodes) { + const dbn = String(node.BDName || '').trim(); + if (!dbn) continue; + const address = adjustMssqlServerForDocker(String(node.ServerName || '').trim()); + const key = sqlServerKey(address); + const password = resolveNodeSqlPassword(node.sql_password); + + let group = byServer.get(key); + if (!group) { + group = { address, serverKey: key, buckets: [] }; + byServer.set(key, group); + } + const bucket = group.buckets.find((b) => b.password === password); + if (bucket) bucket.nodes.push(node); + else group.buckets.push({ password, nodes: [node] }); + } + for (const group of byServer.values()) { + group.buckets.sort((a, b) => b.nodes.length - a.nodes.length); + } + return [...byServer.values()]; +} + +/** Servidores en paralelo. Antes eran 8 NODOS en paralelo, que es lo que multiplicaba los sockets. */ +const SERVER_CONCURRENCY = 4; +/** Bases en paralelo dentro de un mismo servidor, sobre el mismo pool. */ +const DB_CONCURRENCY_PER_SERVER = 4; +/** Días sin restore para considerar una base en alerta. Espejo del HAVING de queryDatabaseAlertRow. */ +const ALERT_STALE_DAYS = 2; + +type NodeMeasured = { kind: 'ok'; measurement: NodeMetricsMeasurement; alertRow: any | null }; +type NodeMissing = { kind: 'missing' }; +type NodeFailed = { kind: 'failed'; reason: string }; +type NodeOutcome = NodeMeasured | NodeMissing | NodeFailed; + +/** + * Recorre los nodos activos del catálogo, consulta SQL Server **una vez por servidor**, persiste lo + * medido y devuelve el bundle. + * + * Dos garantías que antes no existían: + * 1. Un servidor que no contesta cuesta UN intento de conexión (y con el corta-circuitos, ni eso + * hasta que venza el enfriamiento), no uno por base. + * 2. Lo que no se pudo medir sale de la caché durable marcado con su antigüedad, en vez de + * desaparecer del dashboard. */ export async function loadSqlDashboardFromNodes(nodes: CatalogNodeRow[]): Promise { - const databaseRows: any[] = []; - const alertsData: any[] = []; - const restoreHistory: Record = {}; - let totalSizeGb = 0; + const groups = groupNodesByServer(nodes); + const outcomes = new Map(); + const unreachableServers: UnreachableSqlServer[] = []; - // Para cada nodo las 3 consultas (métricas, alerta, historial) son independientes y se lanzan - // en paralelo. Los nodos se procesan con concurrencia acotada para no saturar los pools. - // Antes el patrón era N×3 round-trips en serie contra SQL Server (decenas de segundos). - const NODE_CONCURRENCY = 8; - const perNode = await mapWithConcurrency(nodes, NODE_CONCURRENCY, async (node) => { - const dbn = String(node.BDName || '').trim(); - if (!dbn) return null; - const pwd = resolveNodeSqlPassword(node.sql_password); - try { - const pool = await getMssqlPoolMaster(String(node.ServerName || '').trim(), pwd); - const [row, alertRow, hist] = await Promise.all([ - queryDatabaseMetricsOnServer(pool, dbn), - queryDatabaseAlertRow(pool, dbn), - queryRestoreHistoryForDatabase(pool, dbn) - ]); - // 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( - `SQL Server nodo id=${node.ID} server=${node.ServerName} db=${dbn}:`, - e - ); - return null; + // La caché se lee ANTES del sondeo para poder rellenar lo que falle. Si la tabla no existe + // todavía (primer despliegue), `listNodeMetricsCache` devuelve [] y el flujo sigue igual. + let cached: NodeMetricsCacheRow[] = []; + try { + cached = await listNodeMetricsCache(); + } catch (e) { + logger.warn({ + message: 'No se pudo leer la caché de métricas por nodo; se sigue solo con lo que se mida', + context: { error: describeSqlError(e) } + }); + } + const cachedByNode = new Map(cached.map((row) => [row.database_node_id, row])); + + await mapWithConcurrency(groups, SERVER_CONCURRENCY, async (group) => { + // Los baldes de credenciales se recorren EN SERIE, no en paralelo: así un servidor que no + // contesta cuesta un solo socket aunque tenga varios juegos de credenciales, que es la + // garantía del arreglo. Cada balde mide sus propias bases con su propia credencial —usar el + // pool de otra rompería a las bases cuyo login solo ve la suya y las haría parecer + // inexistentes. + for (let i = 0; i < group.buckets.length; i++) { + const bucket = group.buckets[i]; + let pool: sql.ConnectionPool; + try { + pool = await getMssqlPoolMaster( + group.address, + bucket.password, + undefined, + DASHBOARD_REQUEST_TIMEOUT_MS + ); + } catch (e) { + const reason = describeSqlError(e); + for (const node of bucket.nodes) outcomes.set(node.ID, { kind: 'failed', reason }); + if (!isUnreachableError(e)) { + // Credencial rechazada: el servidor SÍ contestó, así que las demás credenciales + // de este servidor pueden servir. Se sigue con el siguiente balde. + continue; + } + // Nadie contestó: probar otra credencial contra el mismo puerto muerto no aporta + // nada y sí cuesta otro socket. Se abandona el servidor completo y se registra UNA + // vez, no una por base (eso era lo que ahogaba el log del panel). + const pending = group.buckets.slice(i + 1).flatMap((b) => b.nodes); + for (const node of pending) outcomes.set(node.ID, { kind: 'failed', reason }); + const breakerRow = serverBreaker + .snapshot() + .find((r) => r.address === group.serverKey); + unreachableServers.push({ + server: group.address, + reason, + since: breakerRow?.since ?? new Date().toISOString(), + affected_databases: bucket.nodes.length + pending.length + }); + return; + } + + await mapWithConcurrency(bucket.nodes, DB_CONCURRENCY_PER_SERVER, async (node) => { + const dbn = String(node.BDName || '').trim(); + try { + // Las 3 consultas son independientes; la de alerta se conserva (en vez de + // derivarla de last_restore_date) para no cambiar el criterio de alertas de + // rebote: el HAVING compara contra GETDATE() del propio SQL Server, no contra + // el reloj del panel. + const [row, alertRow, hist] = await Promise.all([ + queryDatabaseMetricsOnServer(pool, dbn), + queryDatabaseAlertRow(pool, dbn), + queryRestoreHistoryForDatabase(pool, dbn) + ]); + if (!row) { + outcomes.set(node.ID, { kind: 'missing' }); + return; + } + const visible = String(row.visible_name ?? dbn); + outcomes.set(node.ID, { + kind: 'ok', + alertRow, + measurement: { + databaseNodeId: node.ID, + serverName: group.address, + dbName: dbn, + visibleName: visible, + sizeMb: Number(row.size_mb) || 0, + totalSizeGb: Number(row.total_size_gb) || 0, + lastRestoreDate: row.last_restore_date ?? null, + stateDesc: row.state_desc ?? null, + recoveryModelDesc: row.recovery_model_desc ?? null, + createDate: row.create_date ?? null, + restoreHistory: hist.map((h) => ({ + restore_date: new Date(h.restore_date).toISOString() + })), + effectiveness: computeEffectivenessForHistory(hist) + } + }); + } catch (e) { + // Fallo de ESTA base con el servidor respondiendo (permisos, base en RECOVERY, + // tope de consulta). No es del servidor, así que no toca el corta-circuitos. + outcomes.set(node.ID, { kind: 'failed', reason: describeSqlError(e) }); + } + }); } }); - // Agregación secuencial sobre resultados ya resueltos: evita condiciones de carrera sobre - // las estructuras compartidas y conserva el orden original de los nodos. - for (const res of perNode) { - if (!res) continue; - const { node, dbn, row, alertRow, hist } = res; + await persistOutcomes(nodes, outcomes); + return assembleBundle(nodes, outcomes, cachedByNode, unreachableServers); +} + +/** Escribe lo medido y deja constancia de lo que no se pudo medir. Nunca borra valores buenos. */ +async function persistOutcomes( + nodes: CatalogNodeRow[], + outcomes: Map +): Promise { + const measured: NodeMetricsMeasurement[] = []; + const missing: number[] = []; + const failedByReason = new Map(); + + for (const node of nodes) { + const outcome = outcomes.get(node.ID); + if (!outcome) continue; + if (outcome.kind === 'ok') measured.push(outcome.measurement); + else if (outcome.kind === 'missing') missing.push(node.ID); + else { + const ids = failedByReason.get(outcome.reason); + if (ids) ids.push(node.ID); + else failedByReason.set(outcome.reason, [node.ID]); + } + } + + // La persistencia es best-effort: un fallo aquí no debe tumbar el dashboard, que es justo lo + // que esta caché viene a evitar. + try { + await upsertNodeMetrics(measured); + await markNodeMetricsMissing(missing); + for (const [reason, ids] of failedByReason) { + await markNodeMetricsAttempt(ids, reason); + } + // Limpieza de nodos que ya no existen en el catálogo. La guarda no es cosmética: con la + // lista vacía el DELETE borraría TODA la caché, y un hipo de PostgreSQL al leer el catálogo + // no debe costarnos el último valor conocido de toda la flota. + if (nodes.length > 0) { + const pruned = await pruneNodeMetricsCache(nodes.map((n) => n.ID)); + if (pruned > 0) { + logger.info({ + message: 'Caché de métricas depurada: nodos que ya no están en el catálogo', + context: { rows: pruned } + }); + } + } + } catch (e) { + logger.warn({ + message: 'No se pudo guardar la caché de métricas por nodo', + context: { error: describeSqlError(e), measured: measured.length } + }); + } +} + +/** + * Arma el bundle recorriendo los nodos en el orden del catálogo (no en el del barrido, que ahora va + * por servidor), tomando la medición fresca cuando existe y la caché cuando no. + */ +function assembleBundle( + nodes: CatalogNodeRow[], + outcomes: Map, + cachedByNode: Map, + unreachableServers: UnreachableSqlServer[] +): SqlDashboardBundle { + const databaseRows: any[] = []; + const alertsData: any[] = []; + const restoreHistory: Record = {}; + const effectivenessByDb: Record = {}; + let totalSizeGb = 0; + let staleRows = 0; + let unmeasuredRows = 0; + + const alertThreshold = Date.now() - ALERT_STALE_DAYS * 86_400_000; + + for (const node of nodes) { + const dbn = String(node.BDName || '').trim(); + if (!dbn) continue; + const outcome = outcomes.get(node.ID); + const server = adjustMssqlServerForDocker(String(node.ServerName || '').trim()); // 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 - }); + if (outcome?.kind === 'missing') { + alertsData.push({ visible_name: dbn, last_restore_date: null, not_found: true }); continue; } - const visible = String(row.visible_name ?? dbn); - const keyLower = visible.toLowerCase(); + let visible: string; + let sizeMb: number | null; + let totalGb: number; + let lastRestore: Date | null; + let stateDesc: string | null; + let recoveryModel: string | null; + let createDate: Date | null; + let history: { restore_date: Date }[]; + let effectiveness: { month: string; effectiveness: number }[]; + let measuredAt: string | null; + let stale: boolean; + let alertRow: any | null; + if (outcome?.kind === 'ok') { + const m = outcome.measurement; + visible = String(m.visibleName ?? dbn); + sizeMb = m.sizeMb; + totalGb = m.totalSizeGb ?? 0; + lastRestore = m.lastRestoreDate ? new Date(m.lastRestoreDate) : null; + stateDesc = m.stateDesc; + recoveryModel = m.recoveryModelDesc; + createDate = m.createDate ? new Date(m.createDate) : null; + history = m.restoreHistory.map((h) => ({ restore_date: new Date(h.restore_date) })); + effectiveness = m.effectiveness; + measuredAt = new Date().toISOString(); + stale = false; + alertRow = outcome.alertRow; + } else { + // No se pudo medir: se cae al último valor conocido. Si nunca hubo uno, la base no + // aparece (igual que antes), pero se cuenta para poder decirlo en pantalla. + const c = cachedByNode.get(node.ID); + if (!c || c.measured_at === null || c.missing_since !== null) { + unmeasuredRows += 1; + continue; + } + visible = String(c.visible_name ?? dbn); + sizeMb = c.size_mb; + totalGb = c.total_size_gb ?? 0; + lastRestore = c.last_restore_date ? new Date(c.last_restore_date) : null; + stateDesc = c.state_desc; + recoveryModel = c.recovery_model_desc; + createDate = c.create_date ? new Date(c.create_date) : null; + history = (c.restore_history ?? []).map((h) => ({ + restore_date: new Date(h.restore_date) + })); + effectiveness = c.effectiveness ?? []; + measuredAt = new Date(c.measured_at).toISOString(); + stale = true; + staleRows += 1; + // La alerta de un dato viejo se deriva del último restore conocido, con el mismo umbral + // de días que el SQL de rebote. Aproximación consciente: aquí se compara contra el reloj + // del panel y no contra GETDATE() del servidor, pero la fila ya viaja marcada como vieja. + alertRow = + !lastRestore || lastRestore.getTime() < alertThreshold + ? { visible_name: visible, last_restore_date: lastRestore } + : null; + } + + const keyLower = visible.toLowerCase(); databaseRows.push({ - ...row, visible_name: visible, - original_name: row.original_name ?? visible, + original_name: visible, + size_mb: sizeMb, + total_size_gb: totalGb, + last_restore_date: lastRestore, + state_desc: stateDesc, + recovery_model_desc: recoveryModel, + create_date: createDate, NodoSubNodo: node.NodoSubNodo, client_name: node.Nombre, BDName: dbn, _node_id: node.ID, - _server: adjustMssqlServerForDocker(String(node.ServerName || '').trim()) + _server: server, + /** Cuándo se midió de verdad este dato, y si viene de la caché. */ + _measured_at: measuredAt, + _stale: stale }); - totalSizeGb += Number(row.total_size_gb) || 0; - + totalSizeGb += Number(totalGb) || 0; if (alertRow) alertsData.push(alertRow); if (!restoreHistory[keyLower]) restoreHistory[keyLower] = []; - for (const h of hist) { - restoreHistory[keyLower].push(h); - } + for (const h of history) restoreHistory[keyLower].push(h); + effectivenessByDb[keyLower] = effectiveness; } - const summaryMain = { - total_databases: databaseRows.length, - total_size_gb: Math.round(totalSizeGb * 100) / 100 + return { + databaseRows, + summaryMain: { + total_databases: databaseRows.length, + total_size_gb: Math.round(totalSizeGb * 100) / 100 + }, + alertsData, + restoreHistory, + effectivenessByDb, + unreachableServers, + staleRows, + unmeasuredRows, + generatedAt: new Date().toISOString() }; - - const effectivenessByDb = computeEffectivenessFromHistory(restoreHistory); - - return { databaseRows, summaryMain, alertsData, restoreHistory, effectivenessByDb }; +} + +// ============================================================================ +// Barrido compartido: un solo sondeo para todas las pestañas y todos los endpoints +// ============================================================================ + +/** + * Cuánto vale un barrido antes de repetirlo. Por debajo del auto-refresco de 40 s del dashboard, de + * modo que cada pestaña vea datos frescos, pero varias pestañas (y el SSR, y node-sizes, y el + * reporte de Excel) comparten uno solo en vez de lanzar un barrido cada una. + */ +const DASHBOARD_CACHE_TTL_MS = envInt(env.PANEL_DASHBOARD_CACHE_TTL_MS, 30_000, 0, 600_000); + +let lastBundle: { at: number; bundle: SqlDashboardBundle } | null = null; +let inFlightSweep: Promise | null = null; + +/** + * Copia defensiva del bundle. + * + * Ahora que el barrido se comparte entre peticiones, entregar el mismo objeto sería un error sutil y + * caro: el `load` de la página **muta** `summaryMain` al recalcular totales para un usuario sin + * permisos de admin, y eso corrompería la foto cacheada para todos los demás (y para el siguiente + * admin). Se clona a la profundidad que se toca: filas, alertas, historial y efectividad. + */ +export function cloneDashboardBundle(bundle: SqlDashboardBundle): SqlDashboardBundle { + const restoreHistory: Record = {}; + for (const [key, list] of Object.entries(bundle.restoreHistory)) { + restoreHistory[key] = list.map((h) => ({ ...h })); + } + const effectivenessByDb: Record = {}; + for (const [key, list] of Object.entries(bundle.effectivenessByDb)) { + effectivenessByDb[key] = list.map((e) => ({ ...e })); + } + return { + databaseRows: bundle.databaseRows.map((row) => ({ ...row })), + summaryMain: { ...bundle.summaryMain }, + alertsData: bundle.alertsData.map((alert) => ({ ...alert })), + restoreHistory, + effectivenessByDb, + unreachableServers: bundle.unreachableServers.map((u) => ({ ...u })), + staleRows: bundle.staleRows, + unmeasuredRows: bundle.unmeasuredRows, + generatedAt: bundle.generatedAt + }; +} + +/** + * Último bundle bueno que vio este proceso, sin sondear nada. + * + * Es la red de la página cuando su presupuesto de tiempo se agota: antes, un solo servidor lento + * dejaba el dashboard entero en ceros. Vale `null` recién arrancado el proceso; para ese caso la red + * es la caché durable en PostgreSQL, que `loadSqlDashboardFromNodes` consulta siempre. + */ +export function getLastSqlDashboardBundle(): { at: number; bundle: SqlDashboardBundle } | null { + if (!lastBundle) return null; + return { at: lastBundle.at, bundle: cloneDashboardBundle(lastBundle.bundle) }; +} + +/** + * Arma el dashboard **sin tocar SQL Server**: solo el catálogo y la caché durable, las dos en + * PostgreSQL. + * + * Es la red del arranque en frío. `getLastSqlDashboardBundle` no sirve recién levantado el proceso + * (no hay memoria todavía), y es justo el momento en que el sondeo es más caro: si en ese hueco la + * página se rendía, el operador veía 0 bases y 0 GB. Todas las filas salen marcadas como viejas. + */ +export async function loadSqlDashboardFromCacheOnly(): Promise { + const nodes = (await listDatabaseNodesForMssql()) as CatalogNodeRow[]; + const cached = await listNodeMetricsCache(); + const cachedByNode = new Map(cached.map((row) => [row.database_node_id, row])); + // Sin veredictos: cada nodo cae a su último valor conocido dentro de `assembleBundle`. + return assembleBundle(nodes, new Map(), cachedByNode, []); +} + +/** + * Barrido del dashboard compartido: reusa el resultado reciente, y si hay uno en vuelo se cuelga de + * él en vez de arrancar otro. + * + * Sin esto, N pestañas × 40 s (más cada recarga y cada endpoint) eran N barridos simultáneos, cada + * uno abriendo sus propias conexiones. El `force` es para el reintento manual de un operador. + */ +export async function loadSqlDashboardCached(opts?: { + force?: boolean; +}): Promise { + if (!opts?.force && lastBundle && Date.now() - lastBundle.at < DASHBOARD_CACHE_TTL_MS) { + return cloneDashboardBundle(lastBundle.bundle); + } + // Un barrido en vuelo se comparte incluso con `force`: forzar sirve para no usar el resultado + // viejo, no para abrir una segunda tanda de conexiones contra los mismos servidores. + if (inFlightSweep) return cloneDashboardBundle(await inFlightSweep); + + const run = (async () => { + const nodes = (await listDatabaseNodesForMssql()) as CatalogNodeRow[]; + const bundle = await loadSqlDashboardFromNodes(nodes); + // Solo se cachea el éxito; un barrido que revienta no debe sepultar al último bueno. + lastBundle = { at: Date.now(), bundle }; + return bundle; + })(); + + inFlightSweep = run; + try { + return cloneDashboardBundle(await run); + } finally { + if (inFlightSweep === run) inFlightSweep = null; + } } diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts index 04b89ff..d4e0c2c 100644 --- a/src/routes/+page.server.ts +++ b/src/routes/+page.server.ts @@ -5,7 +5,12 @@ import { redirect } from '@sveltejs/kit'; import type { PageServerLoad, Actions } from './$types'; import { verifyToken } from '$lib/server/auth'; import { getUserById, filterDatabasesByUserPermissions } from '$lib/server/users'; -import { loadSqlDashboardFromNodes, type CatalogNodeRow } from '$lib/server/mssql-nodes'; +import { + getLastSqlDashboardBundle, + loadSqlDashboardCached, + loadSqlDashboardFromCacheOnly, + type SqlDashboardBundle +} from '$lib/server/mssql-nodes'; import { listBackupFiles } from '$lib/server/backup-files'; import { logger } from '$lib/server/logger'; import { @@ -185,22 +190,13 @@ export const load: PageServerLoad = async ({ cookies }) => { restores: null as string | null }; - // --- 1. SQL Server: una conexión por servidor (master) según a24c.database_nodes --- - let nodesForSql: CatalogNodeRow[] = []; - try { - nodesForSql = (await listDatabaseNodesForMssql()) as CatalogNodeRow[]; - } catch (e: any) { - console.error('Error leyendo database_nodes para SQL Server:', e); - errors.primary = `PostgreSQL / database_nodes: ${e.message}`; - } - - // Las métricas de SQL Server (por nodo) y el catálogo de ControlDesk (PostgreSQL) son - // independientes entre sí; se cargan en paralelo para reducir el tiempo total de la página. - // Dentro del catálogo, las 6 consultas también corren en paralelo (antes eran secuenciales). + // --- 1. SQL Server: un sondeo por SERVIDOR (master) según a24c.database_nodes --- + // El barrido se comparte entre pestañas y endpoints (loadSqlDashboardCached) y ya lee dentro el + // catálogo de nodos, así que aquí no se vuelve a leer. let controlDeskOk = false; const [bundleResult, catalogResult] = await Promise.allSettled([ withTimeout( - loadSqlDashboardFromNodes(nodesForSql), + loadSqlDashboardCached(), SQL_LOAD_TIMEOUT_MS, `SQL Server: la carga de métricas excedió ${SQL_LOAD_TIMEOUT_MS} ms` ), @@ -214,8 +210,44 @@ export const load: PageServerLoad = async ({ cookies }) => { ]) ]); + let bundle: SqlDashboardBundle | null = null; + /** El dato que se pinta no es de este momento (viene de memoria o de la caché durable). */ + let bundleDegraded = false; + if (bundleResult.status === 'fulfilled') { - const bundle = bundleResult.value; + bundle = bundleResult.value; + } else { + // DEGRADACIÓN PARCIAL. Antes este camino descartaba el bundle completo y dejaba el + // dashboard en 0 bases y 0 GB: un solo servidor lento borraba de la pantalla a todos los + // sanos. Ahora se cae al último bundle bueno de este proceso y, si el proceso acaba de + // arrancar, a la caché durable en PostgreSQL. El barrido abandonado sigue corriendo (el + // tope solo rechaza, no cancela) y deja todo listo para la próxima carga. + const e: any = bundleResult.reason; + logger.warn({ + message: 'La carga de métricas de SQL Server no terminó dentro del presupuesto', + context: { error: e?.message ?? String(e), budget_ms: SQL_LOAD_TIMEOUT_MS } + }); + const inMemory = getLastSqlDashboardBundle(); + if (inMemory) { + bundle = inMemory.bundle; + bundleDegraded = true; + } else { + try { + bundle = await loadSqlDashboardFromCacheOnly(); + bundleDegraded = true; + } catch (e2: any) { + logger.error({ + message: 'Tampoco se pudo armar el dashboard desde la caché de métricas', + context: { error: e2?.message ?? String(e2) } + }); + } + } + errors.primary = bundle + ? `SQL Server: no se pudo actualizar a tiempo; se muestran los últimos datos conocidos.` + : `SQL Server (nodos): ${e?.message ?? e}`; + } + + if (bundle) { databaseRows = bundle.databaseRows; summaryMain = bundle.summaryMain; alertsData = bundle.alertsData; @@ -223,10 +255,6 @@ export const load: PageServerLoad = async ({ cookies }) => { effectivenessByDb = bundle.effectivenessByDb; databaseRowsAZ = [...bundle.databaseRows]; summaryAZ = { ...bundle.summaryMain }; - } else { - const e: any = bundleResult.reason; - console.error('Error métricas SQL Server por nodo:', e); - errors.primary = `${errors.primary ? errors.primary + ' · ' : ''}SQL Server (nodos): ${e?.message ?? e}`; } if (catalogResult.status === 'fulfilled') { @@ -398,6 +426,15 @@ export const load: PageServerLoad = async ({ cookies }) => { filterDatabasesByUserPermissions(currentUser.id, databaseRows), filterDatabasesByUserPermissions(currentUser.id, alertsData) ]); + // La vista AZ es una copia de las mismas filas y viajaba al cliente SIN filtrar: bases de + // otros clientes a un usuario que no tiene permiso de verlas. + databaseRowsAZ = databaseRows.map((row) => ({ ...row })); + summaryAZ = { + total_databases: databaseRowsAZ.length, + total_size_gb: + Math.round(databaseRowsAZ.reduce((sum, db) => sum + (db.total_size_gb || 0), 0) * 100) / + 100 + }; // Filtrar backups según las bases de datos permitidas (usar NodoSubNodo) const allowedNodos = new Set(databaseRows.map(db => (db.NodoSubNodo || db.visible_name).toLowerCase())); @@ -406,10 +443,14 @@ export const load: PageServerLoad = async ({ cookies }) => { return allowedNodos.has(backupNodo); }); - // RECALCULAR MÉTRICAS basadas en las bases de datos filtradas + // RECALCULAR MÉTRICAS basadas en las bases de datos filtradas. + // `total_databases` también: sin él viajaba al cliente el conteo SIN filtrar, es decir el + // número de bases de otros clientes a un usuario que no tiene permiso de verlas. summaryMain.total_size_gb = databaseRows.reduce((sum, db) => sum + (db.total_size_gb || 0), 0); summaryMain.total_size_gb = Math.round(summaryMain.total_size_gb * 100) / 100; - + summaryMain.total_databases = databaseRows.length; + + restoredCount = 0; notRestoredCount = 0; for (const db of databaseRows) { @@ -448,7 +489,19 @@ export const load: PageServerLoad = async ({ cookies }) => { restoreTargets, restoreHistory, effectivenessByDb, - + + /** + * Estado del sondeo, para que la pantalla pueda distinguir un cero legítimo de un + * "no se pudo consultar". Antes esa diferencia no viajaba y las dos cosas se veían igual. + */ + sqlStatus: { + degraded: bundleDegraded, + generated_at: bundle?.generatedAt ?? null, + unreachable_servers: bundle?.unreachableServers ?? [], + stale_rows: bundle?.staleRows ?? 0, + unmeasured_rows: bundle?.unmeasuredRows ?? 0 + }, + errors, // Return the collected errors currentUser // Añadir usuario actual para la UI }; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 1d4dd96..c481153 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -72,14 +72,53 @@ let summaryMainLive = $state(untrack(() => data.summaryMain ?? null)); let alertsDataLive = $state(untrack(() => data.alertsData ?? [])); - // Métricas simples para las tarjetas del dashboard (derivadas de los datos "live") - let totalDatabases = $state((databaseRowsLive ?? []).length); - let totalSizeGB = $state((() => { + /** + * Estado del sondeo a SQL Server. Sin esto, un 0 por "no se pudo consultar" se veía idéntico a + * un 0 legítimo, que es lo que hizo perder tiempo diagnosticando. + */ + let sqlStatusLive = $state( + untrack( + () => + data.sqlStatus ?? { + degraded: false, + generated_at: null, + unreachable_servers: [], + stale_rows: 0, + unmeasured_rows: 0 + } + ) + ); + + // Métricas de las tarjetas: DERIVADAS de los datos "live", no fijadas una sola vez. Antes + // `totalDatabases` era un $state que nadie volvía a escribir, así que la tarjeta se quedaba en 0 + // para siempre aunque el auto-refresco ya trajera datos buenos. + const totalDatabases = $derived((databaseRowsLive ?? []).length); + const totalSizeGB = $derived.by(() => { const raw = summaryMainLive?.total_size_gb ?? summaryMainLive?.TOTAL_SIZE_GB ?? 0; const val = Number(raw); if (!Number.isFinite(val)) return '0.00'; return val.toFixed(2); - })()); + }); + + /** "hace 2 min" / "hace 3 h" a partir de un ISO; vacío si no hay marca. */ + const humanAge = (iso: string | null | undefined): string => { + if (!iso) return ''; + const ms = Date.now() - new Date(iso).getTime(); + if (!Number.isFinite(ms) || ms < 0) return ''; + const min = Math.floor(ms / 60000); + if (min < 1) return 'hace menos de un minuto'; + if (min < 60) return `hace ${min} min`; + const h = Math.floor(min / 60); + if (h < 24) return `hace ${h} h`; + return `hace ${Math.floor(h / 24)} d`; + }; + + /** ¿Hay algo que advertir del sondeo a SQL Server? */ + const sqlHasWarning = $derived( + (sqlStatusLive?.unreachable_servers?.length ?? 0) > 0 || + sqlStatusLive?.degraded === true || + (sqlStatusLive?.stale_rows ?? 0) > 0 + ); // Vista activa derivada de la URL (?view=); el sidebar global de AppShell la controla. // Vistas adminOnly caen en 'dashboard' si el usuario no es admin. @@ -420,28 +459,64 @@ // Auto-refresh del panel principal (cada 40 segundos) if (typeof window !== 'undefined') { $effect(() => { - const interval = window.setInterval(async () => { + let cancelled = false; + let timer = 0; + /** Fallos seguidos, para espaciar los reintentos en vez de insistir cada 40 s. */ + let consecutiveErrors = 0; + + const tick = async (): Promise => { try { const res = await fetch('/api/dashboard.json'); - if (!res.ok) return; + if (!res.ok) throw new Error(`HTTP ${res.status}`); const payload = await res.json(); + consecutiveErrors = 0; - // No tocar databaseRowsLive aquí para no perder el mapeo al catálogo (client_name, NodoSubNodo) - if (payload?.summaryMain) { - summaryMainLive = payload.summaryMain; - const raw = summaryMainLive?.total_size_gb ?? summaryMainLive?.TOTAL_SIZE_GB ?? 0; - const val = Number(raw); - totalSizeGB = Number.isFinite(val) ? val.toFixed(2) : '0.00'; - } - if (payload?.alertsData) { - alertsDataLive = payload.alertsData; + if (payload?.summaryMain) summaryMainLive = payload.summaryMain; + if (payload?.alertsData) alertsDataLive = payload.alertsData; + if (payload?.sqlStatus) sqlStatusLive = payload.sqlStatus; + + // Las filas SÍ se repueblan, que antes no pasaba: si el SSR corrió durante una + // caída, la tabla y la tarjeta de totales se quedaban en 0 hasta recargar a mano. + // Se fusiona por nodo para no perder el mapeo al catálogo (client_name, + // NodoSubNodo, BDName) que el servidor enriquece solo en la carga de la página. + const fresh: any[] = payload?.databaseRows ?? []; + if (fresh.length > 0) { + if ((databaseRowsLive ?? []).length === 0) { + databaseRowsLive = fresh; + } else { + const byNode = new Map(fresh.map((r: any) => [r._node_id, r])); + databaseRowsLive = databaseRowsLive.map((row: any) => { + const next = byNode.get(row._node_id); + if (!next) return row; + return { + ...row, + size_mb: next.size_mb, + total_size_gb: next.total_size_gb, + last_restore_date: next.last_restore_date, + state_desc: next.state_desc, + recovery_model_desc: next.recovery_model_desc, + _measured_at: next._measured_at, + _stale: next._stale + }; + }); + } } } catch (e) { + consecutiveErrors += 1; console.error('Error auto-refresh dashboard:', e); } - }, 40000); // 40 segundos + if (cancelled) return; + // Retroceso al fallar: 40 s, 80 s, 160 s… con tope de 5 min. Insistir cada 40 s + // contra un panel que ya está sufriendo solo lo empeora. + const delay = Math.min(40_000 * 2 ** Math.min(consecutiveErrors, 3), 300_000); + timer = window.setTimeout(tick, delay); + }; - return () => window.clearInterval(interval); + timer = window.setTimeout(tick, 40_000); + return () => { + cancelled = true; + window.clearTimeout(timer); + }; }); } @@ -1246,6 +1321,57 @@
{#if activeView === 'dashboard'} + + {#if sqlHasWarning} +
+
+ warning_amber +
+ {#if (sqlStatusLive?.unreachable_servers?.length ?? 0) > 0} +

+ {sqlStatusLive.unreachable_servers.length === 1 + ? 'Un servidor SQL no contestó' + : `${sqlStatusLive.unreachable_servers.length} servidores SQL no contestaron`}; + se muestran los últimos datos conocidos. +

+
    + {#each sqlStatusLive.unreachable_servers as srv} +
  • + {srv.server} + — {srv.affected_databases} + {srv.affected_databases === 1 ? 'base' : 'bases'} + {#if srv.since}· sin responder desde {new Date(srv.since).toLocaleString('es-MX')}{/if} +
  • + {/each} +
+ {:else} +

+ Los datos no se pudieron actualizar en este momento; se muestran los + últimos conocidos. +

+ {/if} +

+ {#if sqlStatusLive?.generated_at} + Medición {humanAge(sqlStatusLive.generated_at)}. + {/if} + {#if (sqlStatusLive?.stale_rows ?? 0) > 0} + {sqlStatusLive.stale_rows} + {sqlStatusLive.stale_rows === 1 ? 'base' : 'bases'} con dato anterior. + {/if} + {#if (sqlStatusLive?.unmeasured_rows ?? 0) > 0} + {sqlStatusLive.unmeasured_rows} + sin medición previa (no aparecen en la tabla). + {/if} +

+
+
+
+ {/if} +
@@ -1360,6 +1486,13 @@ ? (Number(row.size_mb) / 1024).toFixed(2) : 0}{' '} GB + {#if row._stale} + + history + {/if} {row.last_restore_date @@ -1381,7 +1514,15 @@ {:else} - No hay datos disponibles o error de conexión. + + {#if sqlHasWarning} + No se pudo consultar SQL Server y no hay mediciones + anteriores guardadas para estas bases. + {:else if mainSearch.trim()} + Ninguna base coincide con la búsqueda. + {:else} + No hay bases de datos registradas. + {/if} {/each} diff --git a/src/routes/api/dashboard.json/+server.ts b/src/routes/api/dashboard.json/+server.ts index 61b4fa8..7867bd1 100644 --- a/src/routes/api/dashboard.json/+server.ts +++ b/src/routes/api/dashboard.json/+server.ts @@ -1,18 +1,108 @@ +/** + * Datos del dashboard para el auto-refresco del navegador (cada 40 s por pestaña). + * + * Tres cosas que este endpoint NO hacía y por las que era el peor camino del panel: no verificaba + * sesión, no filtraba por permisos y no tenía tope de tiempo, así que cualquiera podía disparar el + * barrido completo contra todos los SQL Server, tantas veces como quisiera y con las filas de todos + * los clientes en la respuesta. Ahora comparte el barrido con el resto (`loadSqlDashboardCached`), + * así que N pestañas cuestan un solo sondeo. + */ import { json } from '@sveltejs/kit'; -import { listDatabaseNodesForMssql, lookupAlertClientData } from '$lib/server/controldesk-pg'; -import { loadSqlDashboardFromNodes, type CatalogNodeRow } from '$lib/server/mssql-nodes'; +import { randomUUID } from 'node:crypto'; +import type { RequestHandler } from './$types'; +import { verifyToken } from '$lib/server/auth'; +import { getUserById, filterDatabasesByUserPermissions } from '$lib/server/users'; +import { lookupAlertClientData } from '$lib/server/controldesk-pg'; +import { + getLastSqlDashboardBundle, + loadSqlDashboardCached, + loadSqlDashboardFromCacheOnly, + mapWithConcurrency, + type SqlDashboardBundle +} from '$lib/server/mssql-nodes'; +import { logger } from '$lib/server/logger'; +import { env } from '$env/dynamic/private'; + +/** El mismo presupuesto que el `load` de la página, para que el refresco no dure más que ella. */ +const SQL_LOAD_TIMEOUT_MS = Number(env.PANEL_SQL_LOAD_TIMEOUT_MS) || 12000; +/** Consultas de cliente/correo por alerta, acotadas: antes era un Promise.all sin límite. */ +const ALERT_LOOKUP_CONCURRENCY = 6; + +function withTimeout(promise: Promise, ms: number, message: string): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(message)), ms); + promise.then( + (value) => { + clearTimeout(timer); + resolve(value); + }, + (err) => { + clearTimeout(timer); + reject(err); + } + ); + }); +} + +export const GET: RequestHandler = async ({ cookies }) => { + const traceId = randomUUID(); + + const token = cookies.get('session_token'); + const session = token ? verifyToken(token) : null; + if (!session) { + return json({ error: { code: 401, message: 'No autenticado.', trace_id: traceId } }, { status: 401 }); + } + const currentUser = await getUserById(session.userId); + if (!currentUser || !currentUser.activo) { + return json({ error: { code: 401, message: 'Sesión inválida.', trace_id: traceId } }, { status: 401 }); + } -export const GET = async () => { try { - const nodes = (await listDatabaseNodesForMssql()) as CatalogNodeRow[]; - const bundle = await loadSqlDashboardFromNodes(nodes); - const { databaseRows, summaryMain, alertsData } = bundle; + // Misma degradación parcial que el SSR: si el sondeo no termina a tiempo, se responde con el + // último dato conocido en vez de un cero que se ve igual que un cero legítimo. + let bundle: SqlDashboardBundle; + let degraded = false; + try { + bundle = await withTimeout( + loadSqlDashboardCached(), + SQL_LOAD_TIMEOUT_MS, + `SQL Server: la carga de métricas excedió ${SQL_LOAD_TIMEOUT_MS} ms` + ); + } catch (e: any) { + degraded = true; + const inMemory = getLastSqlDashboardBundle(); + bundle = inMemory ? inMemory.bundle : await loadSqlDashboardFromCacheOnly(); + logger.warn({ + trace_id: traceId, + message: 'Auto-refresco del dashboard degradado a último dato conocido', + context: { error: e?.message ?? String(e) } + }); + } + + let databaseRows = bundle.databaseRows; + let alertsData = bundle.alertsData; + const summaryMain = { ...bundle.summaryMain }; + + if (!currentUser.es_admin) { + [databaseRows, alertsData] = await Promise.all([ + filterDatabasesByUserPermissions(currentUser.id, databaseRows), + filterDatabasesByUserPermissions(currentUser.id, alertsData) + ]); + // Los totales se recalculan sobre lo filtrado; si no, viajaban al cliente el conteo y el + // tamaño de bases que ese usuario no tiene permiso de ver. + summaryMain.total_databases = databaseRows.length; + summaryMain.total_size_gb = + Math.round(databaseRows.reduce((sum, db) => sum + (db.total_size_gb || 0), 0) * 100) / + 100; + } // 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) => { + const enrichedAlerts = await mapWithConcurrency( + alertsData, + ALERT_LOOKUP_CONCURRENCY, + async (alert) => { let clientData: any = null; try { clientData = await lookupAlertClientData(String(alert.visible_name)); @@ -20,12 +110,33 @@ export const GET = async () => { /* ignore */ } return { ...alert, clientData }; - }) + } ); - return json({ databaseRows, summaryMain, alertsData: enrichedAlerts }); + return json( + { + databaseRows, + summaryMain, + alertsData: enrichedAlerts, + sqlStatus: { + degraded, + generated_at: bundle.generatedAt, + unreachable_servers: bundle.unreachableServers, + stale_rows: bundle.staleRows, + unmeasured_rows: bundle.unmeasuredRows + } + }, + { headers: { 'cache-control': 'no-store' } } + ); } catch (e: any) { - console.error('Error refreshing dashboard data:', e); - return json({ error: 'Error refreshing dashboard data' }, { status: 500 }); + logger.error({ + trace_id: traceId, + message: 'Error refrescando los datos del dashboard', + context: { error: e?.message ?? String(e) } + }); + return json( + { error: { code: 500, message: 'Error refrescando los datos del dashboard.', trace_id: traceId } }, + { status: 500 } + ); } }; diff --git a/src/routes/servidores-restauracion/+page.svelte b/src/routes/servidores-restauracion/+page.svelte index 481f93d..bc879eb 100644 --- a/src/routes/servidores-restauracion/+page.svelte +++ b/src/routes/servidores-restauracion/+page.svelte @@ -62,6 +62,8 @@ let sizesError = $state(''); let sizesLoadedAt = $state(null); + /** Cuántos tamaños salieron del último valor conocido porque su servidor no contestó. */ + let sizesStaleCount = $state(0); async function loadNodeSizes(force = false): Promise { if (sizesLoading) return; @@ -79,7 +81,11 @@ for (const s of j.sizes ?? []) map[Number(s.nodeId)] = Number(s.sizeMb) || 0; nodeSizes = map; sizesLoaded = true; - sizesLoadedAt = new Date(); + // La marca es de cuándo se MIDIÓ la foto, no de cuándo llegó la respuesta: el barrido se + // comparte entre pantallas y puede venir cacheado, así que `new Date()` presentaba como + // recién medidos tamaños de hace rato y se repartían bases con datos viejos. + sizesLoadedAt = j.generatedAt ? new Date(j.generatedAt) : new Date(); + sizesStaleCount = Number(j.staleRows) || 0; } catch (e: any) { sizesError = e?.message || 'No se pudieron cargar los tamaños por nodo.'; } finally { @@ -904,7 +910,16 @@ {#if sizesLoadedAt} - Tamaños de las {sizesLoadedAt.toLocaleTimeString('es-MX', { hour: '2-digit', minute: '2-digit' })} + Tamaños medidos a las {sizesLoadedAt.toLocaleTimeString('es-MX', { hour: '2-digit', minute: '2-digit' })} + + {/if} + {#if sizesStaleCount > 0} + + history + {sizesStaleCount} sin medir ahora {/if}
diff --git a/src/routes/servidores-restauracion/node-sizes/+server.ts b/src/routes/servidores-restauracion/node-sizes/+server.ts index 7855471..d439a92 100644 --- a/src/routes/servidores-restauracion/node-sizes/+server.ts +++ b/src/routes/servidores-restauracion/node-sizes/+server.ts @@ -9,8 +9,7 @@ import { randomUUID } from 'node:crypto'; import type { RequestHandler } from './$types'; import { verifyToken } from '$lib/server/auth'; import { getUserById } from '$lib/server/users'; -import { listDatabaseNodesForMssql } from '$lib/server/controldesk-pg'; -import { loadSqlDashboardFromNodes, type CatalogNodeRow } from '$lib/server/mssql-nodes'; +import { loadSqlDashboardCached } from '$lib/server/mssql-nodes'; function errorResponse(code: number, message: string, traceId: string) { return json({ error: { code, message, trace_id: traceId } }, { status: code }); @@ -29,14 +28,28 @@ export const GET: RequestHandler = async ({ cookies }) => { } try { - const nodes = (await listDatabaseNodesForMssql()) as CatalogNodeRow[]; - const bundle = await loadSqlDashboardFromNodes(nodes); + // Comparte el barrido con el dashboard en vez de lanzar uno propio: es la misma foto y así + // abrir esta pantalla no duplica las conexiones a los SQL Server. + const bundle = await loadSqlDashboardCached(); const sizes = bundle.databaseRows.map((row: Record) => ({ nodeId: Number(row._node_id), BDName: String(row.BDName ?? row.visible_name ?? ''), - sizeMb: Number(row.size_mb) || 0 + sizeMb: Number(row.size_mb) || 0, + /** Cuándo se midió ESTE tamaño. Puede venir de la caché durable si su servidor no contestó. */ + measuredAt: (row._measured_at as string | null) ?? null, + stale: row._stale === true })); - return json({ sizes }, { headers: { 'cache-control': 'no-store' } }); + // `generatedAt` es de la foto, no del momento de la respuesta: quien reparte bases por + // capacidad tiene que saber si está planeando con tamaños de hace un rato. + return json( + { + sizes, + generatedAt: bundle.generatedAt, + staleRows: bundle.staleRows, + unreachableServers: bundle.unreachableServers + }, + { headers: { 'cache-control': 'no-store' } } + ); } catch (e: unknown) { const msg = e instanceof Error ? e.message : String(e); console.error(JSON.stringify({ level: 'error', trace_id: traceId, message: 'node-sizes', context: msg }));