Hub

electrosystems

es-antenas-new

active high work
Creado
2026-05-08
Actualizado
2026-05-29
Directorios
  • /home/sergio/code/es-antenas-new
Aliases
monitoreo

Pendientes abiertos (10)

Ver todos →

🎯 Top de ataque (10)

  • #262 📅 2026-06-02 (sigue 2026-05-11) Confirmar con Sergio el resultado de su validación manual de correos. Sergio revisa el comportamiento en su Gmail durante el fin de semana 2026-05-09/10 y comparte si requiere ajuste.
  • #011 📅 2026-06-25 (opcional, follow-up) Considerar el mismo filtro "excluir notificados muy recientemente" en procesarFlapping's expansión de RECORDATORIOS: hoy la query solo filtra whereNotNull('flapping_notificado_at'), así que en un tick donde simultáneamente cae un nuevo flapping (block "detectado") y otro vencía su recordatorio en el mismo sitio, podría salirse el correo "detectado" + "recordatorio" mencionando al recién detectado. Riesgo bajo (requiere 24h-vencido + nuevo en mismo tick). NOTA: con el debounce de detectados (commit 017e1b9) la probabilidad bajó aún más porque el detectado ahora se retrasa 5 min.
  • #012 📅 2026-06-10 Observar próximos eventos de flapping + recordatorios en vivo para confirmar consolidación en ambos caminos tras debounce + arrastre por sitio.
  • #007 📅 2026-06-05 (Fase 3a) Scaffold de tests para monitoreo-collector. El repo Go NO tiene tests hoy — antes de meter el resolver, montar harness: testify + mock de gosnmp (sea gosnmp/gosnmp.MockSNMP o servidor SNMP en docker para integración) + 4-5 tests del resolver de {IF} (cache hit/miss, fallback ifDescr→ifName, interfaz no encontrada, invalidación on noSuchInstance). ~1-2 hrs sesión propia. Sin tocar lógica de producción todavía. Salida: harness pasando + tests muriendo de manera predecible que validen futuras decisiones del resolver.
  • #008 📅 2026-06-12 (Fase 3b) Implementación SNMP placeholder end-to-end. Sub-tareas:
  • #009 📅 2026-06-22 (Fase 4) Migración de datos por lotes. Crear monitoreos nuevos genéricos (ifInOctets {IF}, ifOutOctets {IF}, ifHCInOctets {IF}, ifHCOutOctets {IF}, ifHighSpeed {IF}, ifSpeed {IF}). Empezar piloto con MikroTik (mayor fragilidad — VLANs idx 2001/2005 cambian al menor reboot/restore). Por dispositivo: setear interfaz en cada asignación nueva, validar lecturas correctas durante 1-2 ciclos, soft-deletear las asignaciones viejas hardcodeadas. Auditoría completa: 15 monitoreos ifTable + 62 ifXTable.
  • #010 📅 2026-06-03 Packet loss al AP Rumurachi-Urique (192.168.37.61). Ping desde monitoreo mostró 50% pérdida intermitente durante el diagnóstico. Probable causa de que el monitoreo 166 (ifSpeed.1 LAN1) caiga en falla esporádicamente y se persista como 0. Investigar enlace/RF a Rumurachi (no es es-antenas-new — es operación de red, capturar también en projects/electrosystems-network.md si aplica).
  • #172 📅 2026-06-18 (Fase 6 del flujo "Agregar candidato UISP") Wizard 1-page de Agregar. Unificar todo el flujo en UNA sola vista en lugar del redirect actual Agregar → /dispositivos/{id}/edit. Pantalla nueva (ruta dedicada /uisp-candidatos/{id}/wizard o modal grande en /uisp-candidatos) con: bloque plantilla (con preview de monitoreos que se van a prepoblar), bloque SNMP (community, versión), bloque variables de monitoreo (con botón "Copiar del contraparte" ya implementado en Fase 3), bloque enlace (Fase 5 reutilizable). Submit único: crea dispositivo + prepuebla monitoreos + sobreescribe los límites editados + asigna al enlace, todo en una transacción. Reemplaza el combo "click Agregar → redirect a edit → llenar SNMP → guardar → editar más → enlaces". Depende de Fase 5 ya implementada para el bloque de enlaces. Trade-off: vista más densa, pero el operador no pierde estado entre saltos y todo el contexto del candidato UISP (IP, nombre, plantilla sugerida) queda visible mientras llena.
  • #190 📅 2026-06-20 Retomar /metrics para Prometheus. Hoy quedó desactivado (commit 2efa022) porque el endpoint disparaba una query agregada sobre lecturas_historicas_detalles (49.6M filas) y colgaba PHP-FPM cuando un scraper lo pegaba. Cuando se vuelva a necesitar: (a) reescribir MetricsController::index evitando el eager-load with('ultima_lectura_historica.detalles'); opciones probadas en diagnóstico: loop con DB::table('lecturas_historicas')->where('dispositivo_id', $id)->orderByDesc('fecha')->first() (N=317 queries triviales con lecturas_historicas_dispositivo_id_fecha_index, backward scan, lee 1 fila por query) + 1 query bulk whereIn('lectura_historica_id', $ids) para los detalles, o LATERAL JOIN equivalente en SQL crudo. (b) Restaurar la ruta Route::get('/metrics', [MetricsController::class, 'index']) y el use App\Http\Controllers\MetricsController; en routes/web.php. (c) Considerar autenticarla (hoy era pública). El archivo MetricsController.php quedó intacto en el repo como referencia.
  • #196 📅 2026-06-02 Tepehuanes collector remoto caído. sitios.fecha_ultima_conexion para Tepehuanes a las 13:00 estaba a 487 minutos (~8 horas sin reportarse). Sigue caído. Es problema del HOST físico en sitio (collector Go corriendo en un equipo en Tepehuanes) — no del server. Pendiente operacional: investigar conectividad/equipo en Tepehuanes. Capturar en electrosystems-network-map cuando esté listo.

Actividad en bitácora 11 días

jun
jul
ago
sep
oct
nov
dic
ene
feb
mar
abr
may
L
X
V
Menos
Más

Sergio se refiere a este proyecto como “monitoreo” en conversación (decidido 2026-05-22). El slug del hub queda como es-antenas-new por la regla de slugs estables. Ver memoria reference_monitoreo_alias.md.

Estado al 2026-05-25 (sesión tarde — Sprint 1 deployado en prod)

Sprint 1 (#193) deployado y validado: 0 deadlocks post-deploy

5 commits en master, deployados secuencialmente con 2 hotfixes:

  1. d4b1759 P10 — limpieza de ProcesarMonitoreoDispositivo vacío.
  2. 3ee9cd0 P2 — lock TTL 60s con extend() + comando monitoreo:unlock-stuck-sites + schedule cada 5 min.
  3. 19939e3 P1 — shrink tx + insertOrIgnoreinsert + READ COMMITTED per session.
  4. 1f2c4aa hotfix #1DatabaseLock no soporta extend(); intento de UPDATE atómico en cache_locks.
  5. 5a60758 hotfix #2 — el UPDATE manual fallaba por key prefix (es_monitoreo_cache_*); eliminado el bloque de refresh; TTL del lock a 300s; depender del cron unlock-stuck-sites para huérfanos.

Bug crítico encontrado en P2 (13:08-13:11): workers tiraban Call to undefined method Illuminate\Cache\DatabaseLock::extend(). El proyecto usa CACHE_DRIVER=database, y DatabaseLock (a diferencia de RedisLock/MemcachedLock) no implementa extend(). Resultado: ~130 archivos a failed_syncs/ en 90 segundos. Lección para el doc del #193: la sección P2 asumía extend() disponible sin distinguir entre tipos de lock store. Actualizar el doc con la nota.

Bug en hotfix #1 detectado a los 5 min: el cache_locks almacena keys con prefix (es_monitoreo_cache_sync_lock_sitio_X), no la key raw. Mi UPDATE buscaba la raw → siempre 0 filas → todas las iteraciones del while abortaban → 0 lecturas ingestadas en 5 min, syncs/ creciendo. Más dañino que el bug original.

Hotfix #2 simplifica al modelo correcto para DatabaseLock: TTL 300s sin refresh; si el worker muere, el lock queda huérfano máximo 5 min (vs 10 min con TTL 600s original); el cron monitoreo:unlock-stuck-sites lo limpia adicionalmente. #195 queda cerrado con ventana 5 min (vs 10 min original) — no es la mejora de “0s gap” prometida en el doc por extend(), pero es robusta.

Recovery del backlog acumulado por los 2 bugs (~163 archivos a failed_syncs/): moví failed_syncs/<sitio>/syncs/<sitio>/ con shell (SSH como electrosystems, que está en group www-data, dirs writable) y dispatché 1 job por los 12 sitios afectados. Recovery completo sin pérdida de datos. failed_syncs/ vacío al cierre.

Métricas validadas post-hotfix #2 (ventana 13:17-13:23, ~6 min):

  • 0 deadlocks de ingesta ni del SP de agregados (vs 91 hoy pre-deploy en 13 hrs ≈ 7/hr).
  • 0 errores nuevos en log Laravel.
  • 0 fallos en Fase 3 (la sección post-commit del refactor funciona sin caer en su catch).
  • 1,045 lecturas ingestadas en 5 min — recovery + tráfico normal.
  • failed_syncs/ vacío en todos los sitios.

Estado de #194 y #195 al cierre: ambos en posición de cerrarse después de 24-72 hrs de observación con métricas estables. Aún no se marcan como cerrados porque la validación real requiere ese tiempo de observación. Documentar follow-up: contar deadlocks 2026-05-26 y 2026-05-27 mañana; si 0 → cierre formal.

Sprint 2 — observación clave: los 21 deadlocks/día del SP de agregados también cayeron a 0 en la ventana medida. Hipótesis: READ COMMITTED per-session (P1) beneficia al SP también porque el SP corre como query del mismo job. Si se mantiene en 0 durante 24-72 hrs, P3 (SP a cron dedicado) se descarta como innecesario — Sprint 2 se queda solo con P4 (batch UPSERT) y P9 (cron marcarDispositivosSinDatos), ambos opcionales.

Pre-Sprint — documento de revisión integral entregado

  • #193 documento entregado. Diagnóstico de architect agent sobre app/Jobs/ProcesarSyncCollector.php + monitoreo-collector (Go). Salida en PIPELINE_REVIEW_193.md. TL;DR del documento:
    • La causa raíz de los 133 deadlocks/día (#194) NO es throughput (es ~50 inserts/seg, MySQL aguanta 100×); es la combinación de (a) insertOrIgnore semánticamente vacío porque lecturas_historicas_detalles NO tiene UNIQUE (verificado en migrations) pero cambia el modo de locking de InnoDB, (b) transacción gigante que incluye ~7 side-effects innecesarios (firstOrCreate de estatus por detalle, updateOrCreate EstatusDispositivo, Dispositivo::revisarEstatus con dispatch de event, Sitio::revisarEstatus), (c) isolation REPEATABLE READ que toma gap locks en los 3 índices secundarios + 2 FKs durante el INSERT paralelo.
    • El worker zombie del #195 es un bug separado: Cache::lock("sync_lock_sitio_X", 600) queda vivo 600s si el job revienta por OOM/kill -9 fuera de finally. Los 4 dispatches “vacíos” simplemente vieron el lock ocupado y se retiraron en silencio.
    • Recomendación priorizada (Sprint 1, ~1 día): P1 (4-6 hrs) achicar la tx a solo los 2 INSERTs de histórico + mover todo lo demás post-commit + reemplazar insertOrIgnore por insert + SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED (esto solo lleva los deadlocks de 133/día a 0-5/día sin tocar arquitectura). P2 (2-3 hrs) TTL del lock a 60s con extend() por iteración + cron monitoreo:unlock-stuck-sites cada 5 min (cierra #195).
    • Descartado explícitamente con justificación en el doc: reescribir en Go, reemplazar el modelo file-based de syncs/ (es durable y NO es el cuello), introducir Kafka/Redis Streams, particionar lecturas_historicas_detalles por sitio. Particionar por fecha queda condicional a >100M filas (hoy 49.6M, ~3 años de margen).
    • Sprint 2 condicional: P3 sacar sp_calcular_agregados a cron dedicado, P4 batchear EstatusMonitoreoDispositivo con UPSERT, P9 mover marcarDispositivosSinDatosRecientes a cron.
  • Preguntas abiertas que requieren prod (sección 6 del doc): isolation level actual del MySQL en prod, payload tamaño promedio en failed_syncs/, redis-cli KEYS 'laravel_cache_sync_lock_sitio_*' para confirmar lock huérfano vivo. Queries de verificación read-only listas en sección 7 del doc (anexo).
  • Pendientes que evolucionan a partir del doc: #194 y #195 siguen abiertos pero ahora con causa raíz confirmada y fix recomendado (P1 y P2 respectivamente); cuando Sergio autorice Sprint 1, esos dos son los que aterrizan. No se abrieron IDs nuevos — los pendientes derivados son P1/P2 que cierran #194/#195 directamente.

Estado al 2026-05-23

  • Portal caía con 500 — /metrics desactivado, commit 2efa022, deploy scripts/deploy.sh 98s. routes/web.php exponía /metrics (público, sin auth) → MetricsController.php:15 corría Dispositivo::with('ultima_lectura_historica.detalles')->get() sobre los 317 dispositivos. latestOfMany('fecha') de Laravel genera una subquery con MAX(fecha) GROUP BY monitoreo_dispositivo_id sobre lecturas_historicas_detalles (49.6M filas / 10 GB). Cada hit tardaba 30-60s; un scraper externo apilaba hits hasta saturar PHP-FPM y otros endpoints devolvían 500/502/504. Validado con SHOW FULL PROCESSLIST: 2-3 instancias paralelas de la misma query trabadas hasta 56s. Fix conservador: desactivar la ruta (Sergio confirmó que Prometheus no se usa hoy, retomar después). Post-deploy /metrics → 404 47ms, /dashboard → 302 28ms, MySQL sin queries trabadas. Misma query también la genera el mailable de alertas (Dispositivo::with('ultima_lectura_historica.detalles')), capturada en error de log a las 13:08:39 con “Query execution was interrupted” — el fix definitivo del #190 debe alcanzar también ese codepath.
  • 63 (→ 81 a media tarde) dispositivos sin datos del colector — CAUSA RAÍZ: bug de permisos del log devolvió y revento el pipeline de ingesta. Diagnóstico inicial mío fue ERRÓNEO (sospeché de red interna en sitios). Sergio entró a los collectors remotos y confirmó que los collectors SÍ ven los dispositivos por LAN; el problema estaba entre el collector y el server. Hallazgo real: laravel-2026-05-23.log nació con dueño electrosystems:electrosystems 664 (en vez del usual www-data:www-data 664) porque un cron de electrosystems escribió primero a las 00:00:07. Los workers de Horizon (que corren como www-data, no están en grupo electrosystems) NO podían escribir al log → cada intento de log dentro de ProcesarSyncCollector tiraba chmod() Operation not permitted → el job entero fallaba → failed() movía el archivo a failed_syncs/<sitio>/. Total: 2,528 failed_jobs + 4,817 archivos en failed_syncs/ acumulados desde 00:00. Distribución: Torreón 2,071 / Chihuahua 1,666 / Parral 710 / Hércules 370. Fix de permisos: sudo chown www-data:www-data + chmod 664 al log de hoy (Sergio pegó el comando con ! a las 13:44). Post-fix: 0 nuevos fails, pendientes vivos vacíos en cosa de minutos.
  • Recovery del backlog COMPLETO (13:57 → 21:58). Estrategia evolucionó: primero script con bloques de 200 + polling, pero el script se atoraba porque el polling salía a “pending<10” antes de terminar realmente el batch (Chihuahua procesaba más lento de lo que el script agregaba — ~14 archivos/min vs 22 que metía). Cambié a estrategia simple: mover TODO failed_syncs// → syncs// de una vez + 1 job por sitio; el lock per-sitio del job (Cache::lock("sync_lock_sitio_X")) garantiza serializa por sitio, throttling natural por el loop interno de 4 min + auto-redispatch del propio job. Funcionó para Hércules, Parral, Chihuahua, Torreón y los 7 sitios menores. Tepehuanes (sitio 12) requirió ejecución INLINE vía (new ProcesarSyncCollector(...))->handle() en tinker porque el worker de Horizon no tomaba el job dispatcheado (4 dispatches consecutivos no produjeron logs, pending seguía en 59); inline procesó los 59 en ~60s. Total recuperado: ~5,800 archivos a través de 11 sitios. failed_jobs table limpiada con queue:flush (2,573 → 0). Estado final: pending ≈ 0 en todos los sitios, failed_syncs vacíos.
  • 11 dispositivos siguen marcados “Sin datos recientes” tras el recovery — son problemas REALES, no del bug del log: Tepehuanes 6 (collector remoto del sitio caído desde antes — vimos min_sin_collector=487 al inicio), Torreón 3, Villa Ahumada 2 (probablemente por deadlocks MySQL que siguen ocurriendo). Diferencia con la mañana (cuando había 81): esos eran 100% del bug; los 11 actuales son situaciones legítimas que el operador debe atender.
  • Deadlocks MySQL en lecturas_historicas_detalles durante el día: 133 deadlocks contados en el log. Patrón visto: jobs paralelos de sitios distintos (Torreón + Chihuahua, etc.) intentan INSERT IGNORE INTO lecturas_historicas_detalles con batches grandes (decenas de rows por archivo) y se chocan en algún índice (probablemente el UNIQUE (lectura_historica_id, monitoreo_dispositivo_id) o similar). Cada deadlock → archivo movido a failed_syncs/. Capturado como #194.
  • El “fix” de 2026-05-15 (permission => 0664 en config/logging.php, commit 0ddec4f) NO arregla el problema completo. Solo controla el modo, no el group. Si algún cron corriendo como electrosystems escribe al log antes que www-data, el archivo nace con group electrosystems y www-data no puede escribir aunque sea 664. Pendiente fix de fondo (#192): usar umask 002 en los crons + setgid en storage/logs/ para que el group siempre se herede de la carpeta (www-data), o pasar todos los crons a correr como www-data.
  • Bug colateral en cron NotificarLogs: a las 07:00:02 de hoy reventó con Attempt to read property "enlaces" on null en NotificarLogs.php:686 ($e->monitoreoDispositivo->dispositivo->enlaces->pluck('id') con dispositivo null — relación rota por dispositivo eliminado o soft-deleted). El cron se abortó — alertas por correo de las caídas de hoy NO se enviaron. Pendiente arreglar (#191).

Estado al 2026-05-22

  • Probar conectividad SNMP antes de guardar el dispositivo, commit d73d0ab. Botón “Probar SNMP con esta config” en la sección Config SNMP del form. Envía los valores ACTUALES del form (ip + community + version), no los del modelo en BD — útil cuando se agrega un candidato UISP que viene sin community y el operador la captura antes de guardar. Endpoint POST /dispositivos/probar-snmp (sin dispositivo_id) hace snmpget de sysDescr + sysName con timeout 1.5s (mismo patrón que SnmpSimuladorController). Smart fallback: si la versión solicitada no responde, intenta v2c y v1 automáticamente; si otra versión responde devuelve sugerencia y la UI ofrece “Cambiar a vX” con un botón que autoselecciona en el form. v3 NO entra al fallback (requiere sec_name/sec_level que el form no captura). UI: 3 estados — ✓ verde con sysName+sysDescr / ⚠ amarillo con sugerencia / ✗ rojo con tipo de error (timeout/auth/no_responde). Tests Pest +6 en ProbarSnmpTest (validación de payload + path de timeout con IP RFC 5737 + shape). Suite 262/262 verde (era 256). Build Vite OK. Deploy scripts/deploy.sh 71s. Limitación conocida: dispositivos en sitios remotos detrás de collector NO son alcanzables desde el server monitoreo por LAN; solo equipos del mismo broadcast o via WireGuard responderán — la UI ya lo expresa cuando hay timeout/no_responde.
  • Insertar en medio del enlace con shift automático, commit 0db3ef3. Mejora del modal “Agregar a enlace” del #171. Diagnóstico previo en prod: 22/30 enlaces vivos (73%) son N≥3 (hasta N=19); meter un dispositivo “entre dos que ya están” era el caso dominante y antes el operador tenía que ir a /enlaces/{id}/show a reordenar manualmente. Backend: enlacesDisponibles ahora devuelve el array dispositivos[{id,nombre,posicion}] (ya no posiciones_ocupadas). agregarAEnlace en transacción hace SHIFT +1 a todos los pivots con posicion >= P antes del attach (si P > max el UPDATE es no-op, caso “al final”). Eliminada la validación dura “posición ocupada” — ahora dispara el shift. Frontend: el input numérico de posición se reemplaza por una lista de N+1 radios visualmente legibles (↑ Antes de X, ↕ Entre X y Y, ↓ Después de Z (al final)). Default: insertar al final. Tests +3 de shift (medio / inicio / final-no-shifta), 1 viejo borrado, 1 actualizado al nuevo shape. Suite 256/256 verde (era 254). Build Vite OK. Deploy scripts/deploy.sh 54s.
  • “Copiar de otro dispositivo” generalizado (antes “Copiar del contraparte”), commit 14fac30. Diagnóstico previo en prod reveló que 34/147 dispositivos vivos (~23%) recibían “Sin contraparte aplicable” — 33 por enlaces mixtos (Cambium↔Ubiquiti, etc., el otro extremo con plantilla distinta) y 1 sin enlace todavía. El filtro duro “mismo enlace + misma plantilla” del endpoint anterior contraparteMonitoreos se relajó: ahora fuentesDeCopia lista TODOS los dispositivos con la misma plantilla, marcando es_contraparte=true si comparten enlace y ordenando contraparte → mismo sitio → resto alfabético (limit 50, soporta ?q=). Frontend: el botón abre un modal con autocomplete; contraparte preseleccionada con badge verde; mismo sitio con badge azul; preview de N monitoreos que harán match. Pegar config ahora copia los 3 campos habilitado + limite + ignorar (antes solo limite + ignorar); monitoreos NO presentes en la fuente se DESHABILITAN en el destino para reflejar 1:1 el patrón configurado del fuente. Etiquetas siguen sin copiarse (son por puerto físico). Tests: 3 viejos reemplazados por 6 nuevos (misma-plantilla / excluye-plantilla-distinta / marca-es_contraparte / orden contraparte→mismo-sitio→nombre / filtro q / lista no-vacía sin enlace). Suite 254/254 verde (era 251). Build Vite OK. Deploy scripts/deploy.sh 53s. Endpoint viejo /contraparte-monitoreos ya no existe en prod (verificado con route:list). Esto cubre el problema real que destrababa al #172, así que la decisión final sobre el wizard 1-page queda esperando feedback de Sergio.
  • Fase 5 del flujo “Agregar candidato UISP” cerrada: asignar a enlace desde el edit del dispositivo, commit b6c30f5. Sección “Enlaces” en Dispositivos/Forma.vue (solo en edit) con (a) lista read-only de enlaces actuales (nombre + posición + link a /enlaces/{id}), (b) modal “Agregar a enlace” con autocomplete (mismo sitio, excluye donde ya está) + selector de posición + validación local de colisión, (c) modal “Crear nuevo” inline (crea en sitio del dispositivo, asigna en posición 1), (d) “Quitar” inline. Backend: 4 endpoints nuevos en DispositivosController (GET enlaces-disponibles?q=, POST enlaces {enlace_id, posicion}, POST enlaces/crear-y-asignar {nombre}, DELETE enlaces/{enlace}). Validación dura: mismo sitio + no duplicado + posición no ocupada. Dispositivo::enlaces() ahora con withPivot('posicion'); edit() carga enlaces ordenados. Tras cada cambio → Enlace::actualizarEstatus(). Tests Pest +9 (251/251 verde, era 242). Build Vite OK. Deploy scripts/deploy.sh 72s, sin migraciones, falso positivo “Horizon is inactive” del postflight (memoria #029). Para reordenar/insertar en medio se sigue usando /enlaces/{id}/show. Elimina los pasos 12-13 del flujo manual original. Queda en mesa #6 (wizard 1-page).
  • Flujo “Agregar candidato UISP” simplificado: 3 atajos (combo 1+2+3 de la propuesta), commit cd9b7bb. Sergio reportó que el flujo manual eran 8 pasos (página candidatos → agregar → lista → scroll → ver → editar SNMP → guardar → lista → scroll → editar → monitoreos → enlaces → orden). El cambio elimina los dos scrolls + el ir a buscar el contraparte: (1) UispCandidatosController::agregar redirige al /dispositivos/{id}/edit del nuevo o del claimed, no al index de candidatos; (2) prepoblarMonitoreosDesdePlantilla() inserta todos los monitoreos de la plantilla con limite=0/ignorar=false al crear (MonitoreoDispositivo::insert batch — claim NO los pre-puebla, preserva config manual); (3) endpoint GET /dispositivos/{id}/contraparte-monitoreos (filtra por misma plantilla, devuelve [{dispositivo_id, nombre, enlace_nombre, monitoreos:[{monitoreo_id,limite,ignorar,etiqueta,etiqueta_corta}]}]) + botón “Copiar del contraparte” en Forma.vue sección Variables de Monitoreo — UI carga la lista on-click, si hay 1 pega directo, si hay >1 muestra dropdown, copia solo limite+ignorar (etiquetas son por puerto físico, no se replican). Tests 242/242 (+5 nuevos en Pest). Build Vite OK. Deploy scripts/deploy.sh 99s, sin migraciones. Horizon respawneó por systemd, falso positivo “Horizon is inactive” del postflight es el follow-up conocido del cleanup multi-instance del #029 (nombre dinámico del supervisor). Sergio mencionó que es muy posible que también pida #5 (asignación a enlace dentro del edit) y #6 (wizard 1-page de Agregar).
  • Alias formal: desde hoy Sergio se refiere a este proyecto como “monitoreo” en conversación. Slug del hub queda es-antenas-new por regla de slugs estables; agregado aliases: [monitoreo] al frontmatter + memoria del hub reference_monitoreo_alias.md.

Estado al 2026-05-21

  • Limpieza + fix menor desplegado: commit 57796ba. (1) /public/build a .gitignore + git rm -r --cached de 7 archivos — el deploy regenera todo determinísticamente vía Vite. (2) Bug del conteo de workers en scripts/deploy.sh postflight: el grep estaba bien (manualmente regresa 20); root cause era timing — RestartSec=5 + boot de horizon excedía el sleep 5 fijo. Reemplazado por poll de 30s. El siguiente deploy estrenará el postflight nuevo (el actual usó el viejo porque el shell ya lo tenía cargado antes del pull). Deploy 117s, OK.
  • SNMP Fase 1: Rumurachi-Urique cerrado quirúrgicamente. Soft-delete de monitoreos_dispositivos.id=989 (monitoreo 97 ifSpeed.3 → WLAN en Cambium ePMP, no expone velocidad de aire en IF-MIB) + DELETE FROM monitoreos_plantillas WHERE monitoreo_id=97 AND plantilla_id=20 (Cambium 4600C, preventivo para futuros equipos del modelo). Dispositivo 206 ahora polla solo OIDs propietarios Cambium 17713 (monitoreos 116/117, estables ante reboot). Descubrimiento paralelo: el monitoreo 166 (ifSpeed.1 LAN1) que la bitácora del 2026-05-20 asumía activo en realidad estaba soft-deleted desde 2026-05-04 — el único OID roto era el 97.
  • SNMP Fase 2: RFC cerrado. Diseño consensuado para resolver el problema arquitectural (77 monitoreos hardcodean ifIndex). Placeholder {IF} + columna monitoreos_dispositivos.interfaz + lookup ifDescr con fallback ifName en collector Go + cache SQLite con TTL 1h + error semantic “falla explícita con razón” via razon_falla nuevo en LecturaCombinadaDetalle. Próximas fases: 3a = scaffold de tests del collector (hoy no tiene), 3b = implementación end-to-end, 4 = migración de datos por lotes empezando con MikroTik (mayor fragilidad por VLANs idx 2001/2005). Ver bitácora “Fase 2 RFC” abajo para detalle.

Estado al 2026-05-20

  • Diagnóstico SNMP Rumurachi-Urique: ifIndex no persistente. Sergio reportó Link Speed=0 en dispositivo 206 (192.168.37.61, Cambium 4600C). SNMP confirma: el equipo se rebooteó hace 43 min y los ifIndex se renumeraron — ifSpeed.3 pasó de LAN a WLAN1, y los Cambium ePMP no exponen velocidad de aire en IF-MIB estándar (siempre 0 en WLAN; vive en MIB propietaria 17713). Hallazgo de fondo: hardcodear ifIndex en plantillas SNMP es frágil para todo equipo sin ifIndex persistence (Cambium, Mikrotik, Ubiquiti, etc.). Capturado como pendiente para sesión subsecuente: resolver por ifDescr/ifName en cada poll en lugar de OID literal. Plantilla “Cambium 4600C” como primer caso a migrar; monitoreo 97 debería eliminarse (WLAN no aplica). Ver bitácora 2026-05-20.
  • Fan-out de recordatorios resuelto: arrastre por sitio en procesarAlertas (recordatorios) — commit 74d2e83 en origin/master, deployado a monitoreo vía scripts/deploy.sh (primer uso real del script — 126s, sin migraciones, build limpio, 20 workers systemd activos). Tests 232/232 (+4 nuevos). Sergio reportó una captura de Gmail con ~10 correos del sitio Hercules en 1 hora; diagnóstico confirmó que la cascada a sitio (despacharPendientes, commit a9e1566) solo promovía cuando ≥2 buckets caían en el MISMO tick — con ultimo_notificado_at desfasados por minutos eso casi nunca pasaba. Fix replica el patrón “seeds + arrastre por sitio” que ya tenía procesarFlapping (commit 616d7fc): el primer seed vencido arrastra a todas las métricas en falla del mismo sitio, se manda 1 correo de sitio, y enviarCorreo resincroniza ultimo_notificado_at de todo el grupo. Filtro adicional excluye notificados <5 min (typically en el mismo tick por alertas iniciales) para evitar doble correo en colisión inter-bloque.
  • Fan-out de FLAPPING detectado también resuelto: debounce + arrastre por sitio — commit 017e1b9. Sergio reportó otra captura con 3 correos [FLAPPING] Chihuahua · WiTek Hermosillo · 3 métricas consecutivos (07:00, 07:01, 07:02) + 2 correos [FLAPPING] Hércules · 9 métricas (07:00, 07:02). Diagnóstico: el bloque “detectados” en procesarFlapping agrupa SOLO las métricas que vienen en ese tick; si nuevas métricas cruzan el umbral N-en-15min en ticks consecutivos, cada tick manda su propio correo. Fix: debounce de 5 min en seeds (flapping_desde <= now - debounce_minutos) + arrastre por sitio cuando un seed vence (incluye hermanas con flapping_desde reciente en el mismo correo). Nueva config monitoreo.flapping.debounce_minutos (env MONITOREO_FLAPPING_DEBOUNCE, default 5). Trade-off: la PRIMERA alerta de flapping de un sitio se retrasa hasta 5 min; las hermanas que cruzan el umbral en ese intervalo se consolidan en 1 correo. Tests 237/237 (+4 nuevos). Deploy en 62s vía scripts/deploy.sh. Postflight reportó “workers systemd activos: 0” pero verificación manual: los 20 workers están active running (bug del script — follow-up menor).
  • Reglas persistidas en memoria del hub (Sergio explícito 2026-05-20):
    • feedback-es-antenas-new-push-deploy-authorized.md: push directo a master + deploy via scripts/deploy.sh autorizados por default en este repo. Commit sigue requiriendo auth explícita per-sesión.
    • feedback-prod-read-only-diagnostics-authorized.md: SSH read-only y queries SELECT a MySQL en servidores ES de prod autorizados por default para diagnóstico. Mutaciones (UPDATE/DELETE/sudo) siguen necesitando auth.

Estado al 2026-05-18

  • Limpieza de pendientes stale. Diagnóstico hoy reveló que 3 pendientes ya estaban resueltos por deploys previos: (1) permisos del log daily — commit 0ddec4f ya estaba en master y deployado, los logs desde 2026-05-15 nacen 0664; (2) huérfanos de Horizon — el horizon:terminate del deploy 2026-05-14 los limpió, hoy hay 20 masters tracked por systemd (laravel-worker@1..20.service) y horizon:status reporta running; (3) push notifications — ya cerrado en sesión 2026-05-11, había un pendiente duplicado abierto.
  • failed_jobs (Item 1): FLUSHED. Eran 5,870 (no 14): ProcesarAgregadosSitio 4476, ProcesarSyncCollector 1391, GenerarReporte 3. Causa raíz: el bug de permisos del log volvía fatal cualquier Log::warning() dentro de un job — la escritura al daily file tiraba UnexpectedValueException: Permission denied. Con perms fixed (desde 2026-05-15), 0 nuevas fallas. php artisan queue:flush ejecutado en monitoreo: 5,870 → 0.
  • Cascada FlappingAgrupador aplicada a alertas/recuperaciones (Item 2): commit a9e1566 en origin/master. Refactor en app/Console/Commands/NotificarLogs.php (+191/-69) + tests (+204/-12). Método nuevo despacharPendientes aplica el pase de sitio-promoción. Tests: 197/197 (era 192; +5 nuevos + 2 renombrados). Deploy pausado: el server estaba en branch feature/exportar-csv-uisp con d6c6e7d (otro agente, comando es:exportar-csv-uisp); las branches divergieron y mi commit no es fast-forward. Sergio paró el deploy para sincronizar con el otro agente.
  • Node 20 en server monitoreo + build validado (Item 3, parcial). Sergio corrió setup_20.x + apt-get install nodejs con sudo → Node 20.20.2 + npm 10.8.2 (antes Node 16.20.2 EOL). npm ci + npm run build exitosos en server; el build con Vite 6.2.5 reproduce byte-por-byte el public/build/ commiteado (hashes determinísticos). Falta: mover public/build/ a .gitignore + actualizar runbook — diferido hasta resolver divergencia master/feature.
  • Commit e2c19e4 (UISP migration) faltaba en bitácora. Agregó origen, uisp_id, administrado a dispositivos para homologación UISP. Ya deployado en monitoreo. Detalle en proyecto monitoring-homologation.

Estado al 2026-05-14

  • Captura de Gmail mostró ~20 correos [FLAPPING RECORDATORIO] apilados de Caborca y Terreón, mayoría con “1 métrica” en el asunto. Caso clave: RX y TX del mismo dispositivo AP CU-Esquiveles AF11 (Torreón) llegaron separados 19 min con la misma flapping_desde → confirma que sus flapping_notificado_at están desincronizados.
  • Fix implementado en NotificarLogs::procesarFlapping: la query de recordatorio ahora se hace en dos pasos (seeds que cumplen umbral → arrastrar todos los vecinos del mismo sitio o dispositivo si el sitio es null) y resincroniza flapping_notificado_at para todo el grupo. Cubre Capa 1 (mismo dispositivo) y Capa 2 (mismo sitio) en un solo cambio.
  • 192/192 tests pasan (+3 nuevos en FlappingDetectionTest).
  • Pendiente: aprobación de Sergio para commit + push + deploy en monitoreo.

Estado al 2026-05-12

  • Detección de flapping funcionando (commit 058a22b del 2026-05-11) pero generaba fan-out por métrica: un AP con 5 monitoreos en flapping = 5 correos. Sergio confirmó esta mañana con captura de Caborca: ~15 correos [FLAPPING RESUELTO] en 2 minutos.
  • Cambio aplicado hoy (pendiente de deploy + observación en vivo). Ver bitácora 2026-05-12.

ES Antenas (new)

Contexto

Plataforma interna de Electrosystems para monitoreo de enlaces y dispositivos de red. Recibe métricas SNMP (entre otras) de equipos en redes locales remotas, vía un collector externo (monitoreo-collector). Es el lado del API + dashboard de la dupla.

Va de la mano con monitoreo-collector: los collectors envían datos a este API.

Prioridad alta — proyecto activo importante para la operación.

Tareas pendientes

  • (2026-05-12) Deploy del cambio de agrupación de flapping — desplegado en commit 8e8023b master. Ver bitácora 2026-05-12 abajo.
  • (2026-05-14) Observar próximos correos de flapping — confirmado que el agrupador funciona en correo inicial (caso Netonix 911 con 6 métricas en 1 correo). Reveló nuevo caso: los recordatorios llegaban sueltos porque sus flapping_notificado_at quedan desincronizados entre métricas del mismo dispositivo/sitio. Fix aplicado en working tree, pendiente commit + deploy. Ver bitácora 2026-05-14.
  • (2026-05-11) Push notifications resuelto — instalado php8.2-gmp en server. (Item olvidado de marcar; cerrado en limpieza 2026-05-14.)
  • (2026-05-14) Deploy del fix de recordatorios — commit 616d7fc desplegado en monitoreo. 192/192 tests pasan. Observar mañana ~07:00.
  • (2026-05-18) Limpiar master processes huérfanos de Horizon en monitoreo — verificado hoy: 20 masters tracked por systemd, ninguno huérfano. El horizon:terminate del deploy 2026-05-14 limpió los del 2026-05-12. horizon:status reporta running. (Originalmente capturado 2026-05-14.)
  • (2026-05-18) Permisos del log daily0ddec4f fix(logging): daily logs con permission 0664 ya está en master y deployado. Logs desde 2026-05-15 nacen -rw-rw-r--. (Originalmente capturado 2026-05-12.)
  • (2026-05-18) failed_jobs: 5,870 → 0 vía php artisan queue:flush en monitoreo (autorizado por Sergio). Eran todos artifact del bug de permisos del log; 0 fallas desde 2026-05-15. Distribución original: ProcesarAgregadosSitio 4476, ProcesarSyncCollector 1391, GenerarReporte 3.
  • (2026-05-18) Commit + deploy del refactor de cascada en alertas/recuperaciones — el otro agente mergeó feature/exportar-csv-uisp (6fda261) y borró la branch. Server resincronizado a master y deployado a d07540a. Cascada (despacharPendientes) confirmada cargada en server. Ver Bitácora 2026-05-18 “resolución de divergencia”.
  • (2026-05-18) Aplicar cascada FlappingAgrupador a alertas/recuperaciones normales — refactor implementado en procesarAlertas/procesarRecuperaciones con método nuevo despacharPendientes. Lógica de enlace intacta; pase de sitio-promoción agregado. Ver Bitácora 2026-05-18 para detalle de tests.
  • #262 📅 2026-06-02 — (sigue 2026-05-11) Confirmar con Sergio el resultado de su validación manual de correos. Sergio revisa el comportamiento en su Gmail durante el fin de semana 2026-05-09/10 y comparte si requiere ajuste.
  • (2026-05-18) Investigar por qué no funcionan las notificaciones push (capturado 2026-05-08) — duplicado del item ya cerrado del 2026-05-11. Root cause real: phpseclib3 emitía E_USER_NOTICE por falta de GMP/BCMath, Laravel lo convertía en ErrorException y se tragaba el envío. Fix: sudo apt-get install -y php8.2-gmp + php8.2-fpm reload. Verificado entrega de push de prueba post-fix. Cerrado en limpieza 2026-05-18.
  • (2026-05-18) Instalar Node 20 + verificar build en server monitoreo — hecho. El pendiente decía “no tiene npm”; realidad: tenía Node 16.20.2 (EOL) que no aguantaba Tailwind v4 + Vite 5/6. Upgrade vía NodeSource (setup_20.x + apt-get install nodejs) → Node 20.20.2 + npm 10.8.2. npm ci (605 paquetes, 37s) + npm run build (Vite 6.2.5, 24s) funcionan como user electrosystems. Output del build idéntico byte-por-byte al public/build/ commiteado (hashes determinísticos de Vite). 7/7 archivos. Ver Bitácora 2026-05-18.
  • (2026-05-21) public/build/ a .gitignore + git rm -r --cached — commit 57796ba. 7 archivos eliminados del index; .gitignore agrega /public/build. El deploy del 2026-05-21 regeneró los archivos con npm run build (Vite 6.2.5) sin problemas y git status queda limpio. scripts/deploy.sh ya hacía el build, no hubo que documentar nada nuevo.
  • (2026-05-18) Script de deploy automatizado scripts/deploy.sh — commiteado en d07540a y desplegado al server. Primera prueba real será el siguiente deploy via ssh monitoreo "/var/www/es-monitoreo/scripts/deploy.sh". ✅ Primera prueba real el 2026-05-20: 126s, exit clean, sin migraciones, log a storage/logs/deploys.log.
  • (2026-05-20) Fix de fan-out de recordatorios por sitio (caso Hercules) — commit 74d2e83. Cuando ≥2 enlaces de un sitio entran en falla casi simultánea con ultimo_notificado_at desfasado, el primer seed vencido arrastra a todos los del sitio en falla, se envía 1 correo de sitio, y ultimo_notificado_at queda resincronizado para el siguiente ciclo. Ver bitácora 2026-05-20.
  • #011 📅 2026-06-25 — (opcional, follow-up) Considerar el mismo filtro “excluir notificados muy recientemente” en procesarFlapping’s expansión de RECORDATORIOS: hoy la query solo filtra whereNotNull('flapping_notificado_at'), así que en un tick donde simultáneamente cae un nuevo flapping (block “detectado”) y otro vencía su recordatorio en el mismo sitio, podría salirse el correo “detectado” + “recordatorio” mencionando al recién detectado. Riesgo bajo (requiere 24h-vencido + nuevo en mismo tick). NOTA: con el debounce de detectados (commit 017e1b9) la probabilidad bajó aún más porque el detectado ahora se retrasa 5 min.
  • #012 📅 2026-06-10 — Observar próximos eventos de flapping + recordatorios en vivo para confirmar consolidación en ambos caminos tras debounce + arrastre por sitio.
  • (2026-05-20) Fan-out de FLAPPING detectado: debounce + arrastre por sitio en procesarFlapping — commit 017e1b9. Caso WiTek Hermosillo + Hércules: métricas que cruzaban el umbral N-en-15min en ticks consecutivos generaban 1 correo por tick. Fix: seeds esperan flapping.debounce_minutos (default 5 min) y arrastran hermanas del mismo sitio aún en debounce. Tests 237/237. Ver bitácora 2026-05-20.
  • (2026-05-21) Bug del conteo de workers en scripts/deploy.sh postflight — commit 57796ba. Root cause NO era el grep (manualmente regresa 20 correctamente); era timing: RestartSec=5 en el unit laravel-worker@.service + boot de horizon (~varios segundos) ⇒ el sleep 5 fijo no alcanzaba para que los workers estuvieran active running. Fix: poll hasta 30s, sale en cuanto detecta ≥1. La corrida del 2026-05-21 todavía usó el postflight viejo (script se actualiza en el git pull pero el shell ya lo cargó en memoria) y aun así reportó 20 — horizon:status añadió segundos de buffer; el siguiente deploy estrenará el polling nuevo.
  • (2026-05-21) SNMP Fase 1 — fix puntual Rumurachi-Urique. Soft-delete md_id=989 (monitoreo 97 en dispositivo 206) + delete pivot mp(97,20). Dispositivo 206 ahora solo polla OIDs propietarios Cambium 17713 (monitoreos 116, 117) — estables ante reboot. Monitoreo 97 sigue vivo para Mimosa B11 y Ubiquiti AF11 (6 dispositivos) donde idx=3 sí aplica.
  • (2026-05-21) SNMP Fase 2 — RFC cerrado. Decisiones de diseño consensuadas (ver bitácora 2026-05-21 “Fase 2 RFC” abajo): sintaxis {IF}, columna monitoreos_dispositivos.interfaz (varchar(64) nullable), cache SQLite en collector con TTL 1h, error semantics “falla explícita con razón” via campo nuevo razon_falla en LecturaCombinadaDetalle, tests primero como sesión propia (Fase 3a), implementación end-to-end como Fase 3b, migración de datos manual por lotes como Fase 4.
  • #007 📅 2026-06-05 — (Fase 3a) Scaffold de tests para monitoreo-collector. El repo Go NO tiene tests hoy — antes de meter el resolver, montar harness: testify + mock de gosnmp (sea gosnmp/gosnmp.MockSNMP o servidor SNMP en docker para integración) + 4-5 tests del resolver de {IF} (cache hit/miss, fallback ifDescr→ifName, interfaz no encontrada, invalidación on noSuchInstance). ~1-2 hrs sesión propia. Sin tocar lógica de producción todavía. Salida: harness pasando + tests muriendo de manera predecible que validen futuras decisiones del resolver.
  • #008 📅 2026-06-12 — (Fase 3b) Implementación SNMP placeholder end-to-end. Sub-tareas:
    • Migración Laravel ALTER TABLE monitoreos_dispositivos ADD COLUMN interfaz VARCHAR(64) NULL AFTER ignorar.
    • Migración Laravel: agregar campo razon_falla (varchar(255) nullable) a la tabla de lecturas/ingestion (lecturas_agregadas o donde corresponda — verificar en ProcesarSyncCollector job).
    • CollectorSyncController::getDispositivos: incluir interfaz en el payload del endpoint.
    • ProcesarSyncCollector: leer y persistir razon_falla cuando viene en el push del collector.
    • Collector Go: schema SQLite nueva tabla interface_mappings, struct DeviceMonitor agregar campo Interfaz, resolver de {IF} en doCollection antes del batch Get (pre-walk ifDescr fallback ifName, cache TTL 1h), invalidación en noSuchInstance, OIDs no resueltos se OMITEN del batch (preservar comportamiento de error global del client.Get) y se reportan como Detalle con razon_falla="interfaz X no encontrada".
    • Tests verdes (de Fase 3a).
    • UI mínima: campo interfaz editable en la asignación monitoreos_dispositivos (admin). Opcional Fase 4 si tienes ganas de tinkers por ahora.
  • #009 📅 2026-06-22 — (Fase 4) Migración de datos por lotes. Crear monitoreos nuevos genéricos (ifInOctets {IF}, ifOutOctets {IF}, ifHCInOctets {IF}, ifHCOutOctets {IF}, ifHighSpeed {IF}, ifSpeed {IF}). Empezar piloto con MikroTik (mayor fragilidad — VLANs idx 2001/2005 cambian al menor reboot/restore). Por dispositivo: setear interfaz en cada asignación nueva, validar lecturas correctas durante 1-2 ciclos, soft-deletear las asignaciones viejas hardcodeadas. Auditoría completa: 15 monitoreos ifTable + 62 ifXTable.
  • #010 📅 2026-06-03 — Packet loss al AP Rumurachi-Urique (192.168.37.61). Ping desde monitoreo mostró 50% pérdida intermitente durante el diagnóstico. Probable causa de que el monitoreo 166 (ifSpeed.1 LAN1) caiga en falla esporádicamente y se persista como 0. Investigar enlace/RF a Rumurachi (no es es-antenas-new — es operación de red, capturar también en projects/electrosystems-network.md si aplica).
  • (2026-05-22) Fase 5 del flujo “Agregar candidato UISP” — asignar a enlace desde el edit. Sección “Enlaces” en Forma.vue + 4 endpoints + 9 tests Pest. Commit b6c30f5, deploy 72s. Ver Estado 2026-05-22 arriba.
  • (2026-05-22) “Copiar de otro dispositivo” generalizado (sale del filtro contraparte). Endpoint /contraparte-monitoreos/fuentes-de-copia. 23% de dispositivos en prod dejaron de ver “Sin contraparte aplicable” porque ya no se exige mismo-enlace, solo misma-plantilla. Ahora se copian los 3 campos habilitado+limite+ignorar. Tests 254/254, commit 14fac30, deploy 53s. Ver Estado 2026-05-22 arriba.
  • (2026-05-22) Insertar en medio del enlace con shift automático. Modal “Agregar a enlace” del #171: en vez de input numérico de posición, lista de N+1 slots visuales (↑ Antes de X / ↕ Entre X y Y / ↓ Después de Z). Backend hace shift +1 en transacción a los pivots con posicion >= P. Cubre los 22/30 enlaces N≥3 de prod. Tests 256/256, commit 0db3ef3, deploy 54s. Ver Estado 2026-05-22 arriba.
  • (2026-05-22) Probar conectividad SNMP antes de guardar. Botón “Probar SNMP con esta config” en sección Config SNMP del form (usa valores actuales del form, no del modelo). Smart fallback: si la versión solicitada no responde, intenta v2c y v1 y sugiere cambiar. Tests +6, suite 262/262 verde, commit d73d0ab, deploy 71s. Ver Estado 2026-05-22 arriba.
  • #172 📅 2026-06-18 — (Fase 6 del flujo “Agregar candidato UISP”) Wizard 1-page de Agregar. Unificar todo el flujo en UNA sola vista en lugar del redirect actual Agregar → /dispositivos/{id}/edit. Pantalla nueva (ruta dedicada /uisp-candidatos/{id}/wizard o modal grande en /uisp-candidatos) con: bloque plantilla (con preview de monitoreos que se van a prepoblar), bloque SNMP (community, versión), bloque variables de monitoreo (con botón “Copiar del contraparte” ya implementado en Fase 3), bloque enlace (Fase 5 reutilizable). Submit único: crea dispositivo + prepuebla monitoreos + sobreescribe los límites editados + asigna al enlace, todo en una transacción. Reemplaza el combo “click Agregar → redirect a edit → llenar SNMP → guardar → editar más → enlaces”. Depende de Fase 5 ya implementada para el bloque de enlaces. Trade-off: vista más densa, pero el operador no pierde estado entre saltos y todo el contexto del candidato UISP (IP, nombre, plantilla sugerida) queda visible mientras llena.
  • #190 📅 2026-06-20 — Retomar /metrics para Prometheus. Hoy quedó desactivado (commit 2efa022) porque el endpoint disparaba una query agregada sobre lecturas_historicas_detalles (49.6M filas) y colgaba PHP-FPM cuando un scraper lo pegaba. Cuando se vuelva a necesitar: (a) reescribir MetricsController::index evitando el eager-load with('ultima_lectura_historica.detalles'); opciones probadas en diagnóstico: loop con DB::table('lecturas_historicas')->where('dispositivo_id', $id)->orderByDesc('fecha')->first() (N=317 queries triviales con lecturas_historicas_dispositivo_id_fecha_index, backward scan, lee 1 fila por query) + 1 query bulk whereIn('lectura_historica_id', $ids) para los detalles, o LATERAL JOIN equivalente en SQL crudo. (b) Restaurar la ruta Route::get('/metrics', [MetricsController::class, 'index']) y el use App\Http\Controllers\MetricsController; en routes/web.php. (c) Considerar autenticarla (hoy era pública). El archivo MetricsController.php quedó intacto en el repo como referencia.
  • (2026-05-26) #191 Fix NotificarLogs.php:686Attempt to read property "enlaces" on null — commit e589d67, deployado en 88s vía scripts/deploy.sh. Causa raíz: dispositivo #313 ST Caborca T1-La Gloria C5c soft-deleted 2026-05-22 dejó 5 huérfanos en estatus_monitoreos_dispositivos (md vivo, dispositivo trashed); como EstatusMonitoreoDispositivo::factory query usaba whereHas('monitoreoDispositivo', notificable()) y notificable() no exigía dispositivo vivo, los huérfanos pasaban el filtro y flatMap(... ->dispositivo->enlaces->pluck('id')) reventaba la corrida entera. Fix: agregué ->whereHas('dispositivo') al scopeNotificable de MonitoreoDispositivo — el whereHas respeta el global SoftDeletes scope, así que filtra trashed automáticamente. Como notificable() lo usan los 3 comandos de notificación (NotificarLogs, NotificarDigestNormal, NotificarUrgenteDigest), el cambio blinda el pipeline entero. Defensa-en-profundidad: filtro ?->dispositivo !== null al inicio de incluirVecinosDeEnlace. Test de regresión en NotificarLogsTest. Smoke test post-deploy: php artisan es:notificar-logs corre limpio. Datos huérfanos (21 filas total: 5 con dispositivo trashed + 16 con md trashed) quedan en BD pero ya no se notifican; si Sergio quiere cleanup arquitectónico (cascade soft-delete Dispositivo→md→estatus) capturo pendiente nuevo, lo dejé fuera por scope.
  • (2026-05-25) #193 Revisión integral del pipeline de ingesta — DOCUMENTO ENTREGADO. Diagnóstico completo en PIPELINE_REVIEW_193.md: 12 hallazgos con file:line, 10 propuestas con esfuerzo/impacto/riesgos, recomendación priorizada en 3 sprints + lista explícita de descartes (NO Go, NO Kafka, NO reemplazar file-based, NO particionar por sitio). Sprint 1 son ~6-9 hrs (P1+P2+P10) y resuelve #194 + #195 sin tocar arquitectura. Ver Estado 2026-05-25 arriba.
  • (2026-05-25) #194 Bug deadlocks en lecturas_historicas_detalles — Sprint 1 P1 commiteado, pendiente deploy + validación 48-72h. Commits 19939e3. Tx reducida a solo 2 INSERTs; insertOrIgnoreinsert; SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED. Ver bitácora 2026-05-25.
  • (2026-05-27) #194 Validación 48h+ post-deploy completa — CERRADO. 0 deadlocks 2026-05-26 y 2026-05-27 (criterio ≤10/día). Los 91 deadlocks contados el 2026-05-25 son todos PRE-deploy (último a las 13:06:41 MDT, deploy completó a las 13:16:32). El SP de agregados (sp_calcular_agregados) tampoco generó deadlocks post-deploy — confirmado que READ COMMITTED per-session benefició ambas rutas como anticipaba el doc #193. failed_syncs/ vacío. Ver bitácora 2026-05-27.
  • ARCHIVADO 2026-05-25 — sustituido por entrega P1 (Sprint 1) commit 19939e3 ya commiteado; queda solo validación 48-72h en línea de arriba.
  • (2026-05-25) #195 Worker zombie del lock — Sprint 1 P2 commiteado, pendiente deploy. Commits 3ee9cd0. TTL a 60s + extend() por iteración + comando monitoreo:unlock-stuck-sites cada 5 min. Ver bitácora 2026-05-25.
  • (2026-05-27) #195 Validación 48h+ post-hotfix #2 — CERRADO. 0 locks huérfanos vivos. finally libera por owner correctamente; cron unlock-stuck-sites solo intervino 1 vez en 48h (señal positiva). failed_syncs/ ni existe. Nazareno (id=8, Connection timed out) y Veracruz (id=10, NULL) son problemas separados de collector — capturar como ops si aplica. Ver bitácora 2026-05-27.
  • ARCHIVADO 2026-05-25 — sustituido por entrega P2 (Sprint 1) commit 3ee9cd0 ya commiteado.
  • #196 📅 2026-06-02 — Tepehuanes collector remoto caído. sitios.fecha_ultima_conexion para Tepehuanes a las 13:00 estaba a 487 minutos (~8 horas sin reportarse). Sigue caído. Es problema del HOST físico en sitio (collector Go corriendo en un equipo en Tepehuanes) — no del server. Pendiente operacional: investigar conectividad/equipo en Tepehuanes. Capturar en electrosystems-network-map cuando esté listo.
  • (2026-05-23) #192 Fix de fondo del bug de permisos del log dailysudo chmod g+s /var/www/es-monitoreo/storage/logs/ + sudo chgrp www-data *.log + sudo chmod 664 *.log. Verificado: touch como user electrosystems crea archivo electrosystems:www-data 664 (group heredado del directorio gracias a setgid). El bug no debería volver — independiente del primer escritor del día, todos los logs nuevos nacerán con group www-data y www-data podrá escribir. Memoria del hub reference-laravel-daily-log-perms actualizada con el fix confirmado.

Contexto

Sergio hizo recientemente un cambio en cómo se mandan los correos del sistema. La validación durante el fin de semana mide:

  • ¿Se mandan en los tiempos esperados?
  • ¿Se mandan con la frecuencia correcta (ni de más, ni de menos)?

La validación es manual por Gmail. No requiere acción mía hasta que Sergio diga.

En progreso

(Por confirmar con Sergio.)

Alias

Cuando Sergio dice “el proyecto monitoreo” sin más contexto, se refiere a este (es-antenas-new), no a monitoreo-collector.

Notas técnicas

Stack

  • Laravel 11, PHP 8.2.30
  • Inertia v2 + Vue 3
  • Tailwind v4 (CSS-first config con @theme, sin tailwind.config.js)
  • Laravel Horizon (queues con UI), Sanctum (auth API), Fortify (auth web)
  • Laravel Sail (Docker)
  • Pest 3 + PHPUnit 11

⚠️ Reglas de idioma específicas (del .ai/lenguaje rules del proyecto)

  • UI: español.
  • Nombres de variables: español (excepto términos comunes de código en inglés como counter, container).
  • Comentarios en código: español.
  • Respuestas del agente: inglés, aun cuando Sergio escriba en español.

Esto es opuesto al default del hub (donde respondo en español). Cuando estemos trabajando dentro de este codebase, respondo en inglés.

Inertia v2 features disponibles

Deferred props (con skeletons), infinite scroll con merging props, polling, prefetching, useForm helper.

Bitácora

2026-05-29 — fix del warning de Horizon en deploy.sh aplicado

  • Aplicado el reorden propuesto: el check horizon:status ahora corre después del poll de workers systemd (ya con Horizon respawneado), no antes. Commit 81eab3b en master (push hecho). bash -n OK.
  • Toma efecto en el siguiente deploy (el git pull del inicio de deploy.sh trae el script nuevo); no se requirió re-deploy ahora. Con esto desaparece el Horizon is inactive falso de cada deploy.

2026-05-29 — post-#415: .env alineado + warning de Horizon diagnosticado (falso positivo)

  • Sergio confirmó que el fix de #415 funciona en ambos paths. Pidió (a) alinear el .env de prod por higiene y (b) revisar el warning de Horizon del deploy.
  • (a) .env de prod: SESSION_SECURE_COOKIE true→false (backup /var/www/es-monitoreo/.env.bak-20260529-415). Sin config cache → aplicó directo. El middleware de #415 ya gobernaba por request; esto solo quita la contradicción del archivo base.
  • (b) Horizon — NO hay problema real. horizon:status = running. Corre 1 master por systemd (laravel-worker@1.service, active), artisan horizon + supervisor --balance=auto --max-processes=10 --min-processes=1 --queue=default,aggregates sobre QUEUE_CONNECTION=redis. Es el patrón correcto (1 master systemd escalando interno, no 20 instancias — ver memoria reference_horizon_single_systemd_master).
  • Causa del warning: en scripts/deploy.sh el check horizon:status (líneas ~121-125) corre justo después de horizon:terminate (línea 117) y antes del poll de systemd (líneas ~129-135). En ese instante Horizon está caído (recién terminado, aún sin respawn) → siempre reporta “inactive” → warning falso en cada deploy. El poll posterior sí valida bien (“workers systemd activos: 1”).
  • Fix propuesto (pendiente de aplicar): mover el bloque de horizon:status a después del poll de workers systemd (opcional: con pequeño retry), para que se evalúe ya con Horizon respawneado. Solo afecta deploy.sh; aplica en el siguiente deploy una vez pusheado. Pendiente de autorización de commit.
  • Pidió Sergio: atacar #415, dejando funcionales ambos paths (HTTPS por dominio vía VM reverse_proxy + HTTP por IP en LAN). Eligió el enfoque de middleware dinámico.
  • Diagnóstico afinado: TrustProxies NO estaba configurado → $request->isSecure() daba false en ambos paths; el login HTTPS funcionaba solo porque la cookie secure la guarda el navegador del lado HTTPS. Con SESSION_SECURE_COOKIE=true, el acceso por HTTP/IP descartaba la cookie → 419.
  • Hice (commit a55cddc en master, deployado):
    • Nuevo middleware app/Http/Middleware/ConfigureSecureSessionCookie.php (prepend del grupo web, antes de StartSession/ValidateCsrfToken): fija config(['session.secure' => ...]) por petición = true solo si la request es HTTPS (directo o vía X-Forwarded-Proto del reverse_proxy). Confiar en ese header para esto es seguro (spoofearlo solo haría la cookie MÁS restrictiva).
    • .env.example base a false + comentario explicando que el middleware lo gobierna.
    • 2 tests Pest (SecureSessionCookieTest) verdes. Pint OK.
    • Deploy vía scripts/deploy.sh (nothing to migrate, caches limpiadas) en 162s.
  • Verificado en prod (curl): GET /login por HTTP/IP → cookie XSRF-TOKEN sin secure; con X-Forwarded-Proto: httpscon secure. Ambos paths correctos, login por IP arreglado sin perder el endurecimiento en HTTPS. NO se tocó el .env de prod (el middleware sobreescribe por request).
  • Nota ajena: el deploy reportó Horizon is inactive pero “workers systemd activos: 1” — el proyecto usa workers systemd, no el daemon Horizon; no es regresión de este cambio. Revisar si ese warning del deploy script conviene silenciarlo.
  • Resuelto 2026-05-29 [#415]: error 419 al login por IP 192.168.20.17 — middleware ConfigureSecureSessionCookie (flag Secure por esquema), deployado y verificado en ambos paths.

2026-05-29 — #415 capturado: bug 419 al entrar por IP (reatribuido desde amadeus)

  • Pidió Sergio: investigar en prod el bug “419 al login por 192.168.20.17” (originalmente apuntado en amadeus como #413) y confirmar si de verdad es de amadeus.
  • Investigación read-only en prod: la VM amadeus es 192.168.20.20 y su cookie de sesión es electrosystems_session. La IP 192.168.20.17 emite es_monitoreo_sessiones esta app (monitoreo), no amadeus. Causa del 419: cookies con flag secure servidas por HTTP plano → el navegador no las guarda → POST de login sin CSRF → 419. Detalle en la sección 🐛 Bugs.
  • Resultado: #413 de amadeus reatribuido aquí como #415 (nuevo ID porque #413 ya estaba tomado por vpn-clientes en sesión paralela). Falta decidir/aplicar el fix (redirect HTTP→HTTPS o secure condicional por IP).

2026-05-27 — #195 cerrado: 0 locks huérfanos vivos post-hotfix #2

Pidió Sergio (sesión multi-proyecto): validar #195 en paralelo a #194.

Diagnóstico (read-only en prod monitoreo vía db-investigator subagente):

  • cache_locks: 0 filas al cierre (12:31 MDT). Durante observación se vio 1 lock activo sync_lock_sitio_1 que se liberó por finally del job antes del TTL de 300s.
  • 0 huérfanos vivos.
  • 12/14 sitios actualizados en los últimos minutos. Nazareno (id=8) con Connection timed out en mensaje_conexion y Veracruz (id=10) con campo vacío — ambos fecha_ultima_conexion=NULL. NO son problema de lock; son fallas operacionales de collector como Tepehuanes (#196). Capturar como ops separados si aplica.
  • Cron unlock-stuck-sites está en routes/console.php con everyFiveMinutes()->withoutOverlapping(). Evidencia: exactamente 1 DELETE en cache_locks registrado el 2026-05-27 09:13:27 MDT. Baja frecuencia = señal positiva (el finally casi siempre libera por owner).
  • storage/app/failed_syncs/ no existe. storage/app/syncs/ tampoco. Sólo keys/, private/, public/.

Cierre: criterios cumplidos. #195 marcado [x]. Si Sergio quiere abrir tickets ops por Nazareno + Veracruz, sería un nuevo ID (no parte del lock zombie).

2026-05-27 — #194 cerrado: 0 deadlocks en 48h+ post-deploy Sprint 1 P1

Pidió Sergio (sesión multi-proyecto): validar #194 al cumplirse las 48-72h del criterio de cierre.

Diagnóstico (read-only en prod monitoreo, vía db-investigator subagente):

  • Conteo por día (grep Deadlock en /var/www/es-monitoreo/storage/logs/laravel-YYYY-MM-DD.log):
    • 2026-05-25: 91 — TODOS pre-deploy (último 13:06:41 MDT; el deploy completó 13:16:32 según deploys.log).
    • 2026-05-26: 0.
    • 2026-05-27: 0.
  • Los 91 del 2026-05-25 son del SP sp_calcular_agregados (ON DUPLICATE KEY UPDATE sobre lecturas_agregadas), no ingesta directa.
  • El SP también quedó limpio post-deploy — confirma la hipótesis del doc #193: READ COMMITTED per-session beneficia ambas rutas porque el SP corre como query del mismo job de ingesta. P3 (sacar SP a cron dedicado) descartado como innecesario.
  • failed_syncs/ no existe (completamente limpio).
  • SHOW ENGINE INNODB STATUS último deadlock visible 2026-05-25 13:06:41.

Cierre: criterio ≤10/día cumplido holgadamente. #194 marcado [x] en el doc y PENDIENTES.md.

Implicaciones para Sprint 2 (#193 follow-up): con SP de agregados estable, Sprint 2 queda solo con P4 (batch UPSERT) y P9 (cron marcarDispositivosSinDatos), ambos opcionales — no urgentes.

2026-05-26 tarde — #191 fix de raíz al crash de NotificarLogs (NULL dispositivo)

Pidió Sergio: “Ataquemos el pendiente #191” (proyecto monitoreo).

Diagnóstico (read-only en prod):

  • EstatusMonitoreoDispositivo: 824 totales, 21 huérfanos: 16 con MonitoreoDispositivo soft-deleted (ya filtrados por notificable() via whereHas), 5 con MD vivo pero Dispositivo soft-deleted (no filtrados — éstos crashan).
  • Los 5 huérfanos vivos vienen todos del dispositivo #313 ST Caborca T1-La Gloria C5c (soft-deleted 2026-05-22 15:49:27). Filas estatus_monitoreos_dispositivos.id 801-805, todas en error desde 2026-05-22 15:45:21.
  • IDs #801 y #802 con ultimo_notificado_at = NULL → entran al bucket de “alertas iniciales” → incluirVecinosDeEnlace$e->monitoreoDispositivo->dispositivo->enlaces->pluck('id') revienta porque dispositivo es null.

Fix (commit e589d67, deployado 88s):

  • app/Models/MonitoreoDispositivo.php: scopeNotificable ahora incluye ->whereHas('dispositivo'). Como Dispositivo usa SoftDeletes, el whereHas respeta el global scope y filtra trashed automáticamente — sin tocar nada más, todos los huérfanos quedan invisibles para NotificarLogs, NotificarDigestNormal y NotificarUrgenteDigest (los 3 comandos que usan notificable()).
  • app/Console/Commands/NotificarLogs.php: filtro ?->dispositivo !== null al inicio de incluirVecinosDeEnlace como defensa-en-profundidad para el caso “borrado entre query y procesamiento”.
  • tests/Feature/NotificarLogsTest.php: test de regresión “no revienta ni notifica cuando el dispositivo fue eliminado (soft-delete)” — crea estatus huérfano + estatus sano, soft-deletea el dispositivo del primero, corre el cron, valida exit code 0 + 1 solo correo (el sano).

Verificación:

  • Suite NotificarLogs: 51/51 verde (era 50/50 + el nuevo). Otras suites de notificación (NotificarDigestNormal, NotificarMonitoreosIgnorados, NotificarSitiosOffline): 29/29 verde.
  • Suite global: 263 pasan, 4 falladas pre-existentes (MonitoreoUnlockStuckSitesTest ×3 + ProcesarSyncCollectorP1Test ×1, todas del hotfix #193 — verificado con git stash).
  • Smoke test post-deploy en prod: ssh monitoreo "php artisan es:notificar-logs" → “No hay notificaciones pendientes” (los 5 huérfanos ya se filtraron en el query principal; antes hubieran reventado).

Lo que NO se hizo (out of scope):

  • Limpieza de las 21 filas huérfanas en BD. El fix las ignora pero quedan vivas. Si Sergio quiere, capturo pendiente nuevo para “cascade soft-delete Dispositivo→MonitoreoDispositivo→EstatusMonitoreoDispositivo” como mejora arquitectónica (hoy el soft-delete del dispositivo no toca sus dependencias).

2026-05-25 — Sprint 1 (#193 P10+P2+P1) implementado — 3 commits en master

Pidió Sergio: ejecutar Sprint 1 completo del doc PIPELINE_REVIEW_193.md (P10 + P2 + P1), 3 commits separados, sin push ni deploy.

Commits entregados:

  • d4b1759 — P10: borrado app/Jobs/ProcesarMonitoreoDispositivo.php (handle no-op) + tests/Feature/UniqueMonitoreoJobTest.php. Sin referencias externas de dispatch en prod.
  • 3ee9cd0 — P2: Cache::lock TTL 600s → 60s; extend(60) en cada iteración del while (si retorna false, warning + break para no pisar otro proceso); nuevo comando monitoreo:unlock-stuck-sites que elimina filas de cache_locks con expiration < now() (zombies que no pasaron por finally); schedule everyFiveMinutes en routes/console.php; 3 tests Pest en tests/Unit/Console/MonitoreoUnlockStuckSitesTest.php.
  • 19939e3 — P1: SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED antes de abrir tx; Fase 1 (pre-cómputo en memoria, sin escrituras): EstatusMonitoreoDispositivo y todos los valores acumulados en arrays; Fase 2 (tx mínima): loop insertGetId lecturas_historicas + acumula detalles con ID real + insert() detalles en chunks 500 (ya no insertOrIgnore); Fase 3 (post-commit, try/catch silencioso): EstatusMonitoreoDispositivo::firstOrCreate+actualizarDesdeEstatus + EstatusDispositivo::updateOrCreate + Dispositivo::revisarEstatus; 2 tests Pest en tests/Feature/ProcesarSyncCollectorP1Test.php.

Divergencias del plan vs código real:

  • El doc decía que $this->sitio->update + revisarEstatus estaba DENTRO de la tx (L436-441 antes del commit según el análisis estático). En el código real YA estaba fuera de la tx (después del DB::commit()). Confirmado en lectura del archivo.
  • Cache default del proyecto es database (tabla cache_locks), no Redis. El comando unlock-stuck-sites trabaja con DB::table('cache_locks') en lugar de Redis::keys(). La lógica de “expiración pasada” es la misma (columna expiration como timestamp Unix vs TTL < 0 en Redis).
  • El doc mencionaba App\Console\Kernel.php para el schedule; el proyecto usa routes/console.php (Laravel 11 style). Se usó la convención del proyecto.

Tests: DB de tests no disponible en el entorno (sin Docker). Tests compilaron sin errores de sintaxis. Los tests de DB requerirán el entorno de Sail en prod/dev con BD para correrse.

NO se hizo push ni deploy.

2026-05-25 — #193 entregado: documento de revisión integral del pipeline de ingesta

Pidió Sergio: “vamos a seguir con el #193”. El pendiente pedía un documento de hallazgos + propuestas + recomendación priorizada para el pipeline completo (collector Go + jobs Laravel + MySQL), abierto a cambios de stack.

Cómo se hizo: despaché architect agent con contexto completo (síntomas reales del 2026-05-23, objetivos de Sergio, rutas absolutas de ambos repos, reglas del hub). Sin acceso a prod en esta sesión — el doc es 100% lectura de código + razonamiento sobre el comportamiento de InnoDB.

Entregable: projects/es-antenas-new/PIPELINE_REVIEW_193.md (382 líneas). Convertí projects/es-antenas-new.md plano en projects/es-antenas-new/ carpeta hermana (sin tocar el .md plano, solo agregué el doc nuevo dentro de la carpeta).

Hallazgos principales:

  1. lecturas_historicas_detalles NO tiene UNIQUE (verificado en 3 migrations: 2026_03_31_160851, 2026_04_10_134111, 2026_04_14_154855). El insertOrIgnore es semánticamente vacío pero cambia el modo de locking de InnoDB.
  2. La transacción del job cubre L143-408 — incluye ~7 side-effects que NO necesitan atomicidad (EstatusMonitoreoDispositivo::firstOrCreate+actualizarDesdeEstatus por cada detalle, EstatusDispositivo::updateOrCreate, Dispositivo::find+update+revisarEstatus, Sitio::update+revisarEstatus). Cada operación toma row locks adicionales en tablas distintas.
  3. Cache::lock per-sitio dura 600s en Redis — si el worker revienta por OOM/kill -9, no pasa por finally, lock queda huérfano. Cualquier dispatch en los siguientes 600s se retira silenciosamente.
  4. El “fix de cron” actual del SP sp_calcular_agregados con retry(3, …, 100ms) aplaza pero no resuelve — el SP también compite por locks contra la ingesta.
  5. No se necesita reescribir nada en Go. Throughput real ~50 inserts/seg en detalles + ~3/seg en históricos = ridículamente bajo para MySQL. El problema es código (transacciones + isolation + INSERT IGNORE vacío), no escala.

Recomendación priorizada del doc:

  • Sprint 1 (~1 día): P1 (shrink tx + drop insertOrIgnore + READ COMMITTED, 4-6 hrs) + P2 (lock corto + refresh + unlock-cron, 2-3 hrs) + P10 (limpiar ProcesarMonitoreoDispositivo vacío, 30 min). Métrica: ≤10 deadlocks/día, 0 worker zombies.
  • Sprint 2 condicional: P3 SP agregados a cron (1-2 días), P9 marcarDispositivosSinDatos a cron (2 hrs), P4 batch UPSERT EMD (1 día).
  • Sprint 3 condicional: P6 particionar lecturas_historicas_detalles por mes (3-5 días) — gatillo a >100M filas o queries de dashboard >5s. Hoy son 49.6M, ~3 años de margen.

Descartes explícitos con justificación: P5 reemplazar file-based (el modelo es durable y NO es el cuello; SSDs hacen 100K+ IOPS), P7 particionar por sitio (20 sitios no es shardable rentablemente), P8 reescribir en Go (no resuelve los deadlocks; PHP no es el cuello).

Próximo paso: Sergio decide si autoriza Sprint 1. Si sí, próxima sesión arranca con P1+P2+P10 sobre ~/code/es-antenas-new. Antes de tocar código conviene contestar las 7 preguntas abiertas de la sección 6 del doc (especialmente isolation level actual del MySQL en prod y redis-cli KEYS 'laravel_cache:sync_lock_sitio_*' para confirmar lock huérfano vivo).

Estado de #194 y #195: ambos siguen abiertos pero ahora tienen causa raíz confirmada y fix mapeado (P1 cierra #194, P2 cierra #195). No abrí IDs derivados — son los mismos pendientes con plan de acción concreto.

Repos al día (regla del hub): git fetch en es-antenas-new y monitoreo-collector + pull --ff-only en es-antenas-new (estaba behind 1 de origin/master).

2026-05-21 — SNMP Fase 2 RFC cerrado (sintaxis, schema, cache, error semantic, plan)

Pidió Sergio: “vamos a la siguiente fase” → Fase 2 = RFC del diseño. Sin código en esta sesión; salida = decisiones documentadas para que la Fase 3 pueda ser una sesión limpia.

Exploración previa (sub-agente Explore en monitoreo-collector):

  • Poll batch single-call: internal/collector/snmp.go:GetOIDs usa client.Get(oids) con TODOS los OIDs del dispositivo en una sola llamada. Error global: si UN OID falla, falla el batch entero. Implicación: el resolver de {IF} debe garantizar que NUNCA se incluya un OID no resoluble en el batch.
  • Sin cache hoy: internal/storage/sqlite.go solo tiene dispositivos, monitoreos_dispositivos, pending_syncs. Cache de ifDescr→idx requiere tabla nueva.
  • API payload simple: internal/syncapi/api_client.go tipo DeviceMonitor {ID, DispositivoID, MonitoreoDispositivo, OID, OIDs}. Agregar Interfaz string es trivial.
  • Sin tests: el collector NO tiene tests (búsqueda *test* vacía). Riesgo alto si metemos resolver sin scaffold.
  • Main loop: 3 goroutines en main.go: config-sync 1min, collection 1min wall-aligned, data-sync 30s. doCollection (línea ~170-374) ya pre-agrupa OIDs por dispositivo — buen lugar para inyectar el resolver.

Decisiones del RFC (consensuadas con Sergio vía menús):

A. Sintaxis del placeholder: {IF}

  • Ej: .1.3.6.1.2.1.31.1.1.1.6.{IF} (ifHCInOctets dinámico).
  • Razón: claramente no es OID válido (las llaves rompen el regex [0-9.]+), corto, fácil de leer en BD.
  • Alternativas descartadas: ${if} (colisión visual con env vars), {{IF}} (verbose).

B. Schema en es-antenas-new

  • Migración: ALTER TABLE monitoreos_dispositivos ADD COLUMN interfaz VARCHAR(64) NULL AFTER ignorar.
  • Semántica: si NULL → comportamiento actual (OID se pasa tal cual al collector). Si set Y OID contiene {IF} → resolver dinámico.
  • CollectorSyncController::getDispositivos agrega el campo al payload del endpoint.
  • Validación UI: deferred a Fase 4 (admin puede setear via tinker o SQL directo en Fase 3b).

C. Cache + resolución en collector Go

  • Tabla SQLite nueva: interface_mappings (device_id, interface_name, if_index, resolved_via, resolved_at). resolved_via ∈ {ifDescr, ifName}.
  • Flujo por poll-cycle:
    1. Agrupar OIDs por dispositivo (doCollection ya lo hace).
    2. Si dispositivo tiene OIDs con {IF} y cache stale/missing para alguna interfaz: pre-walk ifDescr (.1.3.6.1.2.1.2.2.1.2) → fallback ifName (.1.3.6.1.2.1.31.1.1.1.1).
    3. Sustituir {IF} por idx resuelto en cada OID antes del batch Get.
    4. Si una asignación no resuelve: omitir del batch + push de detalle con razon_falla. NO tumbar el batch entero (proteger los OIDs que sí resuelven).
  • TTL cache: 1h. Invalidación adicional: en cada noSuchInstance para un OID resuelto via {IF}, invalidar (device, interface) y re-walk el próximo ciclo.

D. Error semantics: falla explícita con razón

  • Sergio escogió esta opción sobre las alternativas (log-only, valor-0-con-flag).
  • Implica extender LecturaCombinadaDetalle con campo razon_falla string (omitempty en Go, nullable en Laravel).
  • Cambio chico pero toca contrato API — ProcesarSyncCollector (job de ingestión) tiene que aceptar y persistir el campo.
  • UI puede mostrar “Interfaz LAN1 no encontrada en dispositivo” en vez de 0 silencioso.

E. Tests del collector (Fase 3a, sesión propia ~1-2 hrs)

  • Sergio escogió “scaffold mínimo + tests del resolver” como pre-requisito antes de la implementación.
  • Stack propuesto: testify + mock de gosnmp (la lib tiene gosnmp/gosnmp.MockSNMP, o usar un servidor SNMP en docker para integración).
  • Casos mínimos del resolver:
    1. Cache hit: dispositivo con cache fresh → no walk, sustitución directa.
    2. Cache miss: dispositivo sin cache → walk ifDescr → match → cache → sustitución.
    3. Fallback: ifDescr no responde o no matchea → fallback a ifName → match → cache.
    4. Interfaz no encontrada: walk completo sin match → omitir OID + reportar razon_falla.
    5. Invalidación: noSuchInstance en un OID resuelto via {IF} → invalidar cache, re-walk próximo ciclo.
  • Sin tests del feature todavía, solo el harness + tests del resolver puro.

F. Migración de datos (Fase 4, manual por lotes)

  • NO migración automática. Los 77 monitoreos hardcodeados conviven con el nuevo modelo indefinidamente.
  • Crear monitoreos genéricos: ifInOctets {IF}, ifOutOctets {IF}, ifHCInOctets {IF}, ifHCOutOctets {IF}, ifHighSpeed {IF}, ifSpeed {IF}.
  • Piloto: MikroTik primero — mayor fragilidad porque VLANs y bonding (idx 2001/2005, 1000010+) cambian al menor reboot/restore.
  • Por dispositivo: setear interfaz en asignaciones nuevas, validar 1-2 ciclos, soft-deletear las viejas hardcodeadas.

G. Lo que NO se hizo hoy

  • Cero código. Cero migraciones. Cero deploys.
  • No agregué TaskCreate porque esta fase fue puro RFC; las próximas fases son sesiones propias que sí merecerán task tracking.

Próximos pasos

  1. Fase 3a (sesión propia ~1-2 hrs): scaffold tests del collector. No bloqueante con ninguna otra cosa del proyecto.
  2. Fase 3b (sesión propia ~2-4 hrs): implementación end-to-end (migración + endpoint + collector + tests + deploy + smoke en 1 dispositivo MikroTik).
  3. Fase 4 (sesión propia, repetible): migración por lotes.

2026-05-21 — SNMP Fase 1: fix puntual Rumurachi-Urique + diseño consensuado para Fase 2-4

Pidió Sergio: “vamos a darle al SNMP” → Fase 1 acotada (fix Rumurachi solo), diseño del placeholder se discute en sesión separada con calma.

Investigación previa al fix:

Mapeo del modelo (sub-agente db-investigator + Explore):

  • monitoreos.llave_valor (string libre) — almacena OID + ifIndex hardcodeado, o lista coma-separada, o path de API propietaria. Sin campo de interfaz.
  • monitoreos_dispositivos (pivot dispositivo↔monitoreo) — tiene limite, multiplicador, divisor, ignorar, deleted_at (SoftDeletes). No tiene campo de interfaz hoy.
  • M:M plantilla↔monitoreo via monitoreos_plantillas con etiqueta override.
  • 77 monitoreos con ifIndex hardcodeado en llave_valor: 15 ifTable (.1.3.6.1.2.1.2.2.1.*.<N>), 62 ifXTable (.1.3.6.1.2.1.31.1.1.1.*.<N>). Idx varían entre 1-15, 25, 2001/2005 (MikroTik VLANs), 1000010/11/12 (EdgeRouter bonding).
  • Polling real vive en monitoreo-collector (Go) con gosnmp — pull cada 60s a GET /api/sitios/{id}/dispositivos, ejecuta gosnmp.Get(oids), push a POST /api/sitios/{id}/sync. El path DatosDispositivo::datosSnmp (Laravel + ext SNMP de PHP) prácticamente no se usa.

Alcance del monitoreo 97 (sub-agente db-investigator, query 4 + cross-checks):

  • Asignado a 7 dispositivos vía monitoreos_dispositivos: 5 Mimosa B11 (plantilla 14), 1 Ubiquiti AF11 (plantilla 19), 1 Cambium 4600C (plantilla 20, el roto = dispositivo 206 Rumurachi-Urique).
  • Vive en 3 plantillas via pivot: SNMP genérica, Mimosa B11, Cambium 4600C.
  • No se puede borrar el monitoreo entero — rompería los otros 6 dispositivos donde idx=3 sí aplica.

Mutaciones autorizadas y ejecutadas (vía php artisan tinker sobre ssh monitoreo, autorización explícita per-sesión de Sergio):

  1. Soft-delete de monitoreos_dispositivos.id=989 (monitoreo 97 en dispositivo 206).

    • Primer intento fue delete() raw que falló con FK violation (tabla lecturas_agregadas tiene FK monitoreo_dispositivo_id).
    • Verifiqué en app/Models/MonitoreoDispositivo.php:27 que el modelo usa SoftDeletes trait, y en app/Http/Controllers/CollectorSyncController.php:25 que el endpoint del collector usa ->with(['monitoreos_dispositivos.monitoreo']) (Eloquent → respeta soft-delete por default).
    • Verifiqué que el flag ignorar SOLO silencia alertas (scopeNotificable en línea 119 del modelo), no detiene el polling. Por eso elegí soft-delete.
    • Ejecutado: App\Models\MonitoreoDispositivo::find(989)->delete()deleted_at = 2026-05-21 14:00:12 MDT. Visible sin withTrashed: NO ✓.
  2. DELETE FROM monitoreos_plantillas WHERE monitoreo_id=97 AND plantilla_id=20 (preventivo — Cambium 4600C ya no hereda el monitoreo a futuros dispositivos del modelo). 1 row deleted. Plantilla 20 ahora solo tiene monitoreos 116 (Downlink Quality) y 117 (Downlink Capacity), ambos OIDs propietarios Cambium 17713 estables.

Descubrimiento paralelo durante la verificación:

El monitoreo 166 (1 Link Speed v1, ifSpeed.1 → LAN1) que la bitácora del 2026-05-20 asumía activo en dispositivo 206 ya estaba soft-deleted desde 2026-05-04 11:53:51 — probablemente cuando Sergio reconfiguró la plantilla 20 ese día (timestamps coinciden con monitoreos_plantillas id=287 del 2026-05-04 12:00). También md_id=1647 (monitoreo 115) está soft-deleted desde 2026-05-04 12:00:52. Esto invalida parte del diagnóstico del 2026-05-20: el único OID activamente roto en dispositivo 206 era el 97; el packet loss al AP sigue siendo problema de red aparte pero no estaba generando alertas en es-antenas-new.

Estado post-fix de dispositivo 206 (AP Rumurachi-Urique 4600C, plantilla 20):

  • md=990 → monitoreo 116 Downlink Quality (OID .1.3.6.1.4.1.17713.21.1.2.30.1.20.1) ✓ activo
  • md=991 → monitoreo 117 Downlink Capacity (OID 1.3.6.1.4.1.17713.21.1.2.30.1.19.1) ✓ activo
  • md=989 → monitoreo 97 ifSpeed.3 ✗ soft-deleted hoy
  • md=1626 → monitoreo 166 ifSpeed.1 ✗ soft-deleted 2026-05-04
  • md=1647 → monitoreo 115 (no investigué qué era) ✗ soft-deleted 2026-05-04

Dispositivo ahora polla solo OIDs propietarios Cambium 17713 — estables ante cualquier reboot porque NO dependen de ifIndex. Es exactamente el outcome deseado para este equipo.

Diseño consensuado para Fase 2-4 (no implementado, capturado como pendiente):

  • Schema: columna nueva monitoreos_dispositivos.interfaz (varchar nullable, p.ej. "LAN1", "WLAN1", "ether3").
  • OID con placeholder: monitoreos.llave_valor admite {IF} donde antes iba .<idx>. Ej: .1.3.6.1.2.1.31.1.1.1.6.{IF}.
  • Collector Go (monitoreo-collector): si OID contiene {IF} y la asignación tiene interfaz, walk a ifDescr (fallback ifName), match exacto, append idx resuelto, gosnmp.Get. Cache ifDescr→idx por dispositivo en SQLite local con TTL 1h. Invalidate en noSuchInstance. Si match no encontrado: registrar falla con razón explícita interfaz "X" no encontrada en lugar de devolver 0 silenciosamente.
  • Back-compat 100%: si OID no tiene {IF} o asignación no tiene interfaz, comportamiento actual.
  • Beneficio largo plazo: los 77 monitoreos hardcodeados colapsan a ~5 (ifInOctets, ifOutOctets, ifHCInOctets, ifHCOutOctets, ifHighSpeed, ifSpeed). Cada dispositivo asigna esos 5 + escoge interfaz por asignación.
  • Decisión de Sergio sobre source-of-truth de interfaz: ifDescr con fallback ifName (estándar histórico, presente en todos los firmwares).

Lo que NO se hizo hoy:

  • Cualquier cambio en monitoreo-collector (Go). El collector sigue como está.
  • Cualquier cambio en schema de es-antenas-new. Sin migraciones nuevas.
  • Cualquier toque a monitoreos en plantilla 14 (Mimosa B11) ni plantilla 19 (Ubiquiti AF11).
  • Cualquier toque al packet loss del enlace Rumurachi (es problema de operación de red, capturado aparte).

2026-05-21 — limpieza: public/build/ untracked + fix conteo de workers en deploy.sh

Pidió Sergio: “empecemos con limpieza y bugs menores”.

Cambios (commit 57796ba, 9 files, +13/-169):

  1. /public/build en .gitignore + git rm -r --cached public/build/ (7 archivos).

    • Justificación: Vite con hash determinístico ⇒ el build del server reproduce byte-por-byte (validado el 2026-05-18). Versionarlos solo agregaba ruido al diff.
    • El deploy regenera los archivos vía npm ci && npm run build (que ya estaba en scripts/deploy.sh).
    • Ventana de “no hay assets” durante el deploy: ~60s (npm ci + build). Aceptable porque deploys son raros y fuera de horas pico. Esta ventana ya existía parcialmente porque los hashes de Vite cambian de versión a versión.
  2. Fix postflight worker count en scripts/deploy.sh (scripts/deploy.sh:119-141).

    • Síntoma reportado el 2026-05-20: “workers systemd activos: 0” en el log del deploy, cuando en realidad había 20 corriendo.
    • Root cause confirmado por SSH read-only: el grep está bien (systemctl list-units 'laravel-worker@*' --no-legend | grep -c 'active running' regresa 20 correctamente). El problema es timing — laravel-worker@.service tiene RestartSec=5 y horizon tarda varios segundos extra en bootear, así que el sleep 5 + 1 check quedaba justo antes de que los workers pasaran a active running.
    • Fix: poll de hasta 30s con paso de 2s, sale en cuanto detecta ≥1 worker activo. Si tras 30s sigue en 0, warn (no error — el deploy ya terminó).

Deploy del 2026-05-21 12:32 MDT: 117s, sin migraciones, npm ci+npm run build regeneraron public/build/ con manifest+sw+4 assets (Vite 6.2.5). El postflight ejecutado fue el VIEJO (el shell ya tenía cargado el script en memoria antes del git pull); aun así reportó 20 workers — probablemente porque horizon:status añadió segundos de buffer entre el sleep 5 y el grep. El siguiente deploy estrenará el postflight nuevo con polling.

Verificación post-deploy:

  • ssh monitoreogit log -1 = 57796ba
  • grep -c 'deadline=' scripts/deploy.sh = 1 (nuevo polling presente) ✓
  • ls public/build/ = assets/, manifest.json, manifest.webmanifest, sw.js (regenerados) ✓
  • git status --short public/build/ vacío (ya ignorado correctamente) ✓

Lo que falta: próximo deploy para estrenar el postflight nuevo y confirmar que el conteo es estable.

2026-05-20 — diagnóstico SNMP: ifIndex no es persistente (caso Rumurachi-Urique)

Pidió Sergio: “el snmp del dispositivo 192.168.37.61 me marca Link Speed en 0, dispositivo 206”.

Diagnóstico ejecutado (read-only, autorizado por default):

  1. BD es-antenas-new (vía sub-agente db-investigator): dispositivo 206 = AP Rumurachi-Urique 4600C, plantilla “Cambium 4600C” (id=20), 2 monitoreos de Link Speed asignados:

    • Monitoreo 97 “3 Link Speed v1” → OID .1.3.6.1.2.1.2.2.1.5.3 (ifSpeed, idx=3).
    • Monitoreo 166 “1 Link Speed v1” → OID .1.3.6.1.2.1.2.2.1.5.1 (ifSpeed, idx=1).
    • Estado: ambos en advertencia con valor_inicio_falla=0. Monitoreo 166 en falla desde 2026-05-01; monitoreo 97 entró hoy 17:19 UTC.
    • Última lectura “completa exitosa” del dispositivo: 2026-04-09 (los OIDs propietarios Cambium 17713 sí responden y siguen OK; el dispositivo entero queda en advertencia por el Link Speed).
    • Community SNMP: cambiumsnmp v2c.
  2. SNMP directo (ssh monitoreo → snmpwalk a 192.168.37.61):

    ifDescr:        1=LAN interface 1   2=LAN interface 2   3=WLAN interface 1   4=WLAN interface 2
    ifSpeed:        1=1Gbps             2=4293967296        3=0                  4=0
    ifHighSpeed:    1=1Gbps             2=4293967296        3=0                  4=0
    ifOperStatus:   1=up                2=down              3=up                 4=up
    sysUpTime:      0:42:49.63 (≈43 min)
  3. Causa raíz confirmada: Sergio reportó que “hace media hora ifSpeed.3 era LAN”. sysUpTime de 43 min coincide exactamente — el equipo se rebooteó, los ifIndex se renumeraron. Antes del reboot el idx 3 era LAN (1 Gbps); después del reboot idx 3 es WLAN1 (0 por diseño del firmware ePMP — no expone velocidad de aire en IF-MIB estándar, solo en MIB propietaria Cambium 17713).

  4. Cambium 4600C ifSpeed por interfaz (caracterización del modelo, no del dispositivo):

    • LAN interfaces: reportan velocidad real cuando link up (1 Gbps); reportan 4293967296 (≈2³² − 1M = basura típica) cuando admin=up pero oper=down. ifHighSpeed igual.
    • WLAN interfaces: siempre ifSpeed=0 y ifHighSpeed=0, sin importar asociación. Por diseño del firmware. La velocidad de aire vive en OIDs propietarios Cambium (1.3.6.1.4.1.17713.*), que es justo donde sí funcionan los monitoreos 116/117 (Downlink Quality / Capacity).

Hallazgo paralelo: ping desde monitoreo a 192.168.37.61 mostró 50% packet loss durante la sesión. Eso explica también por qué el monitoreo 166 (ifSpeed.1 = LAN1) cae intermitentemente — cuando hay timeout, el sistema persiste el último valor o lo trata como 0. Capturado como pendiente aparte (problema de red, no de es-antenas-new).

Implicación de fondo (para sesión subsecuente):

El sistema es-antenas-new hardcodea índices SNMP en las plantillas (.X.<idx>). En equipos sin ifIndex persistence (RFC 2863) — que son la mayoría de embebidos: Cambium ePMP, Mikrotik, Ubiquiti airOS, switches baratos — los índices se reasignan en cada reboot/firmware-update/driver-reload. Cualquier reboot puede silenciosamente convertir un monitoreo correcto en uno apuntando a otra interfaz, sin que el sistema avise. Caso de hoy: el reboot reordenó WLAN antes que LAN.

Opciones de solución (a evaluar en sesión subsecuente):

  1. Resolver índice por nombre en cada poll: walk a ifDescr (o ifName) → buscar match → consultar ifX.<idx_resuelto>. Requiere cambio en el modelo de plantilla (campo nuevo “nombre de interfaz” en lugar de “OID literal”) y en el collector. Robusto y portable.
  2. Usar SNMP INDEX semántica (snmpget con auxiliar de tabla): equivalente conceptualmente, mismo costo de implementación.
  3. Para Cambium específicamente, migrar Link Speed a OIDs propietarios Cambium 17713 (estables porque son fijos por concepto, no por índice de tabla).

Para el caso puntual de Rumurachi-Urique:

  • Monitoreo 97 (ifSpeed.3 → WLAN1): nunca va a dar valor útil en este modelo. Hay que eliminarlo o re-apuntarlo a OID propietario.
  • Monitoreo 166 (ifSpeed.1 → LAN1): conceptualmente OK (backhaul cableado), pero queda como muestra del problema general.

Estado de prod tras esta sesión: sin cambios. Diagnóstico puro, read-only. Pendiente capturado para atacar en sesión subsecuente.

2026-05-20 — debounce + arrastre por sitio en flapping detectado (fan-out de WiTek/Hércules)

Pidió Sergio: segunda captura de Gmail (post-recordatorios fix), mostrando que los correos [FLAPPING] también llegan repetidos del mismo sitio:

  • [FLAPPING] Chihuahua · WiTek Hermosillo · 3 métricas a 07:00, 07:01 y 07:02 (3 correos consecutivos).
  • [FLAPPING] Hércules · 0 enlaces · 3 dispositivos · 9 métricas a 07:00 y 07:02 (2 correos en 2 minutos).
  • Más tarde a 07:04: [ALERTA] Hércules + [DIGEST URGENTE - FIN QUIET HOURS] 5 alertas, 19 flapping.

Diagnóstico: el bloque “detectados” en NotificarLogs::procesarFlapping procesaba todas las métricas con flapping_desde IS NOT NULL y flapping_notificado_at IS NULL en cada tick. Si nuevas métricas del mismo sitio cruzan el umbral N-en-15min en ticks distintos (07:00, 07:01, 07:02), cada tick procesa solo las recién cruzadas → 1 correo por tick. La cascada FlappingAgrupador agrupa lo que recibe; no tiene contexto del correo del tick anterior.

Acceso a prod autorizado per-sesión por Sergio: “sí, autoriza SSH read-only para el diagnóstico para esta y futuras sesiones”. El classifier requirió mensaje explícito en chat para SSH y para DB reads (Sergio re-confirmó). Después se persistió la regla en memoria del hub.

Decisión de diseño (Sergio eligió por menú): debounce de N min en detectados + arrastre por sitio (espejo del patrón ya aplicado a recordatorios el mismo día). Razón: el debounce solo (sin arrastre) seguiría generando varios correos consecutivos cuando hermanas crucen el umbral en ticks distintos; el arrastre solo (sin debounce) no daría chance a que las hermanas se acumulen antes de disparar el primer correo. Juntos, garantizan 1 correo por sitio por evento.

Implementación (NotificarLogs.php:160-230):

  1. Seeds: flapping_desde IS NOT NULL, flapping_notificado_at IS NULL, flapping_desde <= now - debounce_minutos, notificable, tier urgente.
  2. Arrastre: si hay seeds, cargar TODAS las métricas en flapping pendiente del mismo sitio (o dispositivo si no tiene sitio), incluso con flapping_desde muy reciente.
  3. Despacho: enviarFlappingAgrupado recibe el conjunto completo → FlappingAgrupador lo colapsa en grupos dispositivo/enlace/sitio → 1 correo por grupo → marca flapping_notificado_at = now para todo el conjunto (resincroniza).

Config nueva (config/monitoreo.php:135-148):

  • monitoreo.flapping.debounce_minutos = env('MONITOREO_FLAPPING_DEBOUNCE', 5).

Trade-off: la PRIMERA alerta de un sitio entrando a flapping se retrasa hasta debounce_minutos. Las hermanas que cruzan el umbral en ese intervalo se consolidan en 1 correo. Sergio aceptó.

Tests (tests/Feature/FlappingDetectionTest.php): +4 nuevos:

  • “detectado: con debounce activo, métrica recién entrada a flapping NO se notifica hasta cumplir el debounce”.
  • “detectado: seed vencido arrastra hermanas del mismo sitio aunque su flapping_desde sea reciente”.
  • “detectado: con debounce activo, sin seeds vencidos no se manda nada (todos en debounce)”.
  • “detectado: arrastre no cruza sitios (solo agrupa hermanas del mismo sitio del seed)”.

Ajustes a tests existentes:

  • Helper crearEstatusFlappingPendiente actualizado para flapping_desde = now - 10 min (antes now - 5 min), así los tests que lo usan no se acoplan al debounce default.
  • Test “NotificarLogs envía correo flapping detectado…” (línea 158) ahora pone config()->set('monitoreo.flapping.debounce_minutos', 0) explícito porque ejercita el mecanismo de transiciones con ventana de 5 min.

Suite: 237/237 ✓ (eran 232 al cierre del fix anterior).

Commit y deploy:

  • Commit local: 017e1b9 fix(flapping): debounce + arrastre por sitio en detectados (3 archivos, +185/-11).
  • Push autorizado per-sesión por Sergio.
  • Deploy: ssh monitoreo "/var/www/es-monitoreo/scripts/deploy.sh" — 62s, sin migraciones, npm run build con sw.mjs + 12 entries precache. Postflight reportó “workers systemd activos: 0” pero verificación manual: los 20 laravel-worker@*.service están active running, 80 procesos horizon corriendo. Bug del conteo en el script (capturado como pendiente menor).
  • Verificación: git log -1 en server = 017e1b9.

Reglas persistidas en memoria del hub (autorización explícita de Sergio):

  • feedback-es-antenas-new-push-deploy-authorized.md: push directo a master + deploy via scripts/deploy.sh autorizados por default en este repo. Commit sigue requiriendo auth per-sesión.
  • feedback-prod-read-only-diagnostics-authorized.md: SSH read-only y SELECT a MySQL en servidores ES de prod autorizados por default. Mutaciones siguen necesitando auth.

Lo que falta observar: próximo evento de flapping en cualquier sitio. Esperamos que cuando varias métricas crucen el umbral N-en-15min con minutos de diferencia, salga 1 solo correo por sitio en lugar de 3-5 consecutivos.

Follow-ups menores:

  • Bug del conteo de workers en scripts/deploy.sh postflight (workers systemd activos: 0 cuando hay 20).
  • Aplicar mismo “excluir notificados <5 min” en expansión de recordatorios de flapping (probabilidad ahora más baja por el debounce de detectados).

2026-05-20 — arrastre por sitio en recordatorios (fan-out de Hercules)

Pidió Sergio: captura de Gmail mostraba muchos correos 📊 RECORDATORIO Hercules - <X> repetidos en pocas horas (Notario RB Camargo, Hercules-1, Hercules-2, Bolsa Tete, Colonia del Notario…), todos del mismo sitio Hercules con monitoreos eléctricos. “¿Se puede hacer algo para que sean menos correos los que llegan tan seguido?”

Diagnóstico (read-only): el sitio Hercules tenía varios enlaces en falla cuyo ultimo_notificado_at quedó desfasado por minutos (cada uno cruzó el umbral inicial en un tick distinto). La cascada a sitio del 2026-05-18 (despacharPendientes, commit a9e1566) promueve a 1 correo cuando hay ≥2 buckets en el mismo tick; pero con los ultimo_notificado_at desincronizados, cada tick procesaba 1 enlace suelto → 1 correo por enlace cada 120 min. Mismo patrón que ya se había arreglado para flapping el 2026-05-14 (commit 616d7fc) pero nunca aplicado a alertas/recordatorios normales.

Implementación: NotificarLogs.php:113-181 — el bloque de recordatorios ahora hace 2 queries (espejo de procesarFlapping):

  1. Seeds: estatuses cuyo ultimo_notificado_at venció el intervalo (la query original).
  2. Expansión: todos los estatuses en falla del mismo sitio_id (o mismo dispositivo_id si no tiene sitio), con ultimo_notificado_at ya seteado pero no demasiado reciente. El conjunto completo pasa a procesarAlertasdespacharPendientes que promueve a 1 correo de sitio. enviarCorreo ya hace $estatuses->each(fn ($e) => $e->update(['ultimo_notificado_at' => $now])) (línea 719), así todo el grupo queda resincronizado y los próximos ciclos vencen juntos.

Filtro adicional descubierto en test: la expansión excluye estatuses con ultimo_notificado_at <min_failure_duration (5 min) — sin esto, un dispositivo que acaba de recibir su alerta inicial en el bloque 2 del mismo tick entraba como “recordatorio” en el bloque 3, generando 2 correos del mismo dispositivo en segundos. Caso atrapado por el test “NO arrastra alertas iniciales”.

Trade-off aceptado: una métrica notificada hace 30 min recibirá su “recordatorio” antes de los 120 min, pero llega agrupada con sus hermanas del mismo sitio en 1 correo. Es la consolidación pedida; no es ruido adicional. Mismo trade-off que el fix de flapping 2026-05-14.

Tests (tests/Feature/NotificarLogsTest.php): +4 nuevos:

  • “recordatorio: arrastra vecinos del mismo sitio con ultimo_notificado_at no vencido y los agrupa en 1 correo de sitio”.
  • “recordatorio: resincroniza ultimo_notificado_at de todo el grupo arrastrado al enviar”.
  • “recordatorio: NO arrastra estatus de otros sitios”.
  • “recordatorio: NO arrastra alertas iniciales (ultimo_notificado_at NULL) aunque sean del mismo sitio” — atrapó el filtro de “muy recientes” antes del deploy.

Suite: 232/232 ✓ (eran 197 al 2026-05-18; las 35 nuevas vienen mayormente de la sesión monitoring-homologation 2026-05-19 con altas/bajas UISP).

Commit y deploy:

  • Commit local: 74d2e83 fix(recordatorios): arrastre por sitio para evitar fan-out de N correos cada 2h (2 archivos, +219/-10).
  • Push autorizado per-sesión por Sergio (classifier bloqueó la primera vez; Sergio respondió “sí, autoriza el push y deploy”).
  • Deploy: ssh monitoreo "/var/www/es-monitoreo/scripts/deploy.sh"primer uso real del script (commiteado el 2026-05-18). Resultado: 126s, sin migraciones (Nothing to migrate.), npm run build produjo sw.mjs + 12 entries precache idénticos, optimize:clear limpio, horizon:terminate mató 20 procs, postflight registró 20 workers systemd activos. horizon:status reporta inactive (ruido conocido — los workers via systemd no se registran como horizon master; documentado desde 2026-05-18).
  • Verificación: git log -1 en server = 74d2e83; grep -c seedsRecordatorio app/Console/Commands/NotificarLogs.php = 3 (definición + 2 referencias) → código nuevo cargado.

Lo que falta observar: próximo ciclo de recordatorios (en las próximas 2 horas). Esperamos que cuando Hercules siga con varios enlaces en falla, salga 1 solo correo de sitio en lugar de N correos de enlace. Si Sergio captura el comportamiento, perfecto.

Follow-up identificado (no fixed): procesarFlapping’s query de expansión (línea 222-237) tiene el mismo issue teórico — un nuevo “detectado” en el mismo tick que un “recordatorio” del mismo sitio podría salir mezclado. Riesgo bajo (requiere 24h-vencido + nuevo flapping en mismo sitio en mismo tick). Capturado como tarea pendiente opcional.

2026-05-18 — limpieza de pendientes stale + diagnóstico real de failed_jobs

Pidió Sergio: avanzar pendientes en es-antenas-new.

Hice (sólo diagnóstico, read-only en server):

  1. Permisos del log daily. Revisé config/logging.php en el repo — commit 0ddec4f ya tiene 'permission' => 0664 en el canal daily. Verifiqué git log y ssh monitoreo "git log": el commit está en origin/master y deployado. ls -la storage/logs/ en server: logs desde 2026-05-15 nacen -rw-rw-r--. Los históricos (12/13/14) siguen 644 pero son legacy; no bloquean. Pendiente cerrado.

  2. Huérfanos de Horizon. El pendiente decía “33 procesos colgados del 2026-05-12”. Verifiqué hoy: ps -eo pid,ppid,stime muestra 20 masters todos del 2026-05-14, todos tracked por systemd (systemctl show 'laravel-worker@*' confirma los 20 MainPID). 98 procesos totales = 20 masters + 20 supervisors + 58 workers (esto es la baseline normal, no orphans). El horizon:terminate del deploy 2026-05-14 limpió los del 12. php artisan horizon:statusHorizon is running. Pendiente cerrado — la implementación con systemd unit template laravel-worker@.service ya tiene el comportamiento que pedía el pendiente (KillMode systemd-default mata el árbol al reiniciar).

  3. Failed jobs. El pendiente decía “14 del 2026-04-14 de ProcesarAgregadosSitio”. Realidad: 5,870 totales distribuidos ProcesarAgregadosSitio 4476 / ProcesarSyncCollector 1391 / GenerarReporte 3. Inspección de stack trace (id 45232, 45231, 45230): el error es siempre UnexpectedValueException: The stream or file "...laravel-2026-05-11.log" could not be opened in append mode: Failed to open stream: Permission denied. La excepción “occurred while attempting to log” el warning [Sync Warning] Error procesando archivo sincronización. Se moverá a fallos: .... Es decir, el job estaba manejando un sync corrupto como warning pero la propia llamada Log::warning() murió por permisos y mató el job. Causa raíz = el bug de permisos del log (ya fixed). Verificado: failed_jobs WHERE failed_at >= '2026-05-15' = 0. Distribución por fecha: 2026-04-14 → 2892, 2026-04-15 → 1586, 2026-05-11 → 1375, + smattering menor. Pendiente actualizado con números reales.

  4. Push notifications duplicado. El pendiente “Investigar push notifications” (capturado 2026-05-08) es el mismo problema que ya se cerró el 2026-05-11 con php8.2-gmp. Cerrado, sin diagnóstico adicional necesario.

  5. Commit e2c19e4 no estaba en bitácora. feat(dispositivos): origen + uisp_id + administrado para homologación UISP — agregado fuera de sesión, ya en master y deployado. Sólo lo anoto aquí; el detalle vive en projects/monitoring-homologation/.

Decisión que necesito de Sergio (no ejecuté):

  • Para los 5,870 failed_jobs: ¿queue:flush masivo (recomendado — eran warnings y agregados de hace 5+ semanas, retry hoy es ruido) o queue:retry? Si quieres mantener evidencia forense, puedo dumpear los stack traces a un .jsonl antes de flushear.

Lo que NO toqué (autorización requerida):

  • Instalar Node/npm en server (sudo).
  • Mover monitoreos throughput restantes — no aplica, ya cubierto en 2026-05-11.

Item 1: queue:flush (autorizado)

ssh monitoreo "cd /var/www/es-monitoreo && php artisan queue:flush"INFO All failed jobs deleted successfully. Verificación: failed_jobs.count() = 0.

Item 2: cascada FlappingAgrupador aplicada a procesarAlertas / procesarRecuperaciones (autorizado, NO commiteado)

Diseño: aditivo, sin tocar la lógica de enlace ya validada en producción. En lugar de mandar correo dentro de los loops de enlace + individuales, ambos métodos ahora acumulan “buckets pendientes” (cada uno con sitio, enlace?, dispositivos, estatuses) y delegan al método nuevo despacharPendientes que aplica la cascada de sitio: si ≥2 buckets comparten sitio_id, salen como 1 correo a nivel sitio ($enlace=null, dispositivos planos, estatuses fusionados). El mailable NotificacionEstatusDispositivo y el blade estatus_markdown ya soportaban ese caso (@if($enlace && $esMultiple) en línea 36 hace que el header de “Enlace:” solo aparezca cuando hay enlace explícito) — sin cambios ahí ni en el web push (el tag('dispositivo-'.sitio_id) ya estaba keyed por sitio).

Cambios:

  • NotificarLogs.php:299-385: procesarRecuperaciones reescrito para acumular en $pendientes y delegar.
  • NotificarLogs.php:399-516: procesarAlertas reescrito igual; mantiene incluirVecinosDeEnlace y la verificación “contieneElegible” sin tocar.
  • Método nuevo despacharPendientes: groupBy por sitio_id, promueve ≥2 buckets en mismo sitio a 1 correo, también ejecuta el cleanup de banderas de recuperación (necesita_notificacion_recuperacion=false, minutos_en_falla_ultima_racha=null, tier_actual=null).

Tests (tests/Feature/NotificarLogsTest.php): 46/46 ✓ (43 antes + 5 nuevos + 2 renombrados con assertion actualizado):

  • Renombrados (assertion: 2 → 1, reflejan nueva política): “agrupa recuperaciones a nivel sitio cuando 2 dispositivos del mismo sitio se recuperan fuera de la ventana de enlace” y su gemelo de alertas.
  • Nuevos: “envia recuperaciones separadas cuando 2 dispositivos están en sitios distintos y fuera de la ventana de enlace” + su gemelo de alertas (asegura que la promoción NO aplica cross-sitio).
  • Nuevo: “alertas: promueve a sitio cuando un enlace agrupado y un dispositivo suelto comparten sitio”.
  • Nuevo: “alertas: 2+ buckets de enlaces distintos del mismo sitio se promueven a 1 correo de sitio”.
  • Nuevo: “recuperaciones: 2+ buckets de enlaces distintos del mismo sitio se promueven a 1 correo de sitio y limpian banderas” (verifica además que necesita_notificacion_recuperacion/minutos_en_falla_ultima_racha/tier_actual quedan limpios para los 4 estatuses).

Suite completa: ./vendor/bin/sail test197/197 pasa (era 192). 26.72 s. 0 regresiones.

Estado del working tree (no commiteado):

  • Branch actual: feature/exportar-csv-uisp (al mismo commit que master, 0ddec4f).
  • Sergio tiene WIP separado en esta branch: archivo nuevo app/Console/Commands/ExportarCsvUisp.php untracked.
  • Mis cambios: app/Console/Commands/NotificarLogs.php (+191/-69) y tests/Feature/NotificarLogsTest.php (+204/-12). Independientes de la WIP de Sergio.

Decisión pendiente de Sergio antes de commitear: ✅ resuelto el 2026-05-18 — commit directo en master, NO tocar ExportarCsvUisp.php (el otro agente lo maneja), deploy con runbook anterior.

Commit hecho: a9e1566 feat(notificaciones): cascada a sitio en procesarAlertas/procesarRecuperaciones. Pusheado a origin/master. 2 archivos: app/Console/Commands/NotificarLogs.php (+191/-69) + tests/Feature/NotificarLogsTest.php (+204/-12). 326 inserciones, 69 deleciones.

Deploy PAUSADO — al intentar git pull --ff-only origin master en monitoreo: el server estaba en branch feature/exportar-csv-uisp con commit d6c6e7d feat(dispositivos): comando es:exportar-csv-uisp para Fase 1A de homologación deployado por el otro agente. Las branches divergieron: master tiene mi cascada pero no ExportarCsvUisp, feature/exportar-csv-uisp tiene ExportarCsvUisp pero no la cascada. Ningún commit incluye ambos. Sergio decidió pausar el deploy hasta sincronizar con el otro agente (probablemente merge feature/exportar-csv-uispmaster desde fuera).

Item 3: instalar Node/npm en server (autorizado, COMPLETADO en parte)

El pendiente decía “el server no tiene npm”. Realidad verificada hoy: Node 16.20.2 + npm 8.19.4 sí estaban instalados pero la versión es EOL (2023) y no aguanta Tailwind v4 + Vite 6 del package.json actual. El runbook implícito de “buildear local y commitear public/build/” era el síntoma — no la causa.

Upgrade ejecutado (Sergio corrió los sudo):

curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt-get install -y nodejs

Resultado: NodeSource source.list cambió de node_16.x focalnode_20.x nodistro. Versión: Node 20.20.2 + npm 10.8.2.

Build verificado por mí (sin sudo) en /var/www/es-monitoreo/ como user electrosystems:

  1. npm ci605 paquetes en 37s, sin errores (21 vulnerabilities en dev deps, todas conocidas, no runtime).
  2. npm run buildVite 6.2.5, 24s. Output:
    • public/build/manifest.json (0.51 KB)
    • public/build/manifest.webmanifest (0.57 KB)
    • public/build/assets/app-DMDLZakx.css (83.75 KB)
    • public/build/assets/app-hPjdF-Zk.js (275.50 KB)
    • public/build/assets/vendor-0JEVkNtd.js (369.84 KB)
    • public/build/assets/jsonview-DWGlM-MN.js (3.21 KB)
    • public/build/sw.mjs (16.53 KB) + sw.js (17.5 KB, generado por PWA plugin)
  3. git status public/build/ después del build: nothing to commit, working tree clean. Es decir: el build del server con Node 20 reproduce byte-por-byte los archivos commiteados. Hashes determinísticos de Vite. 7 archivos commiteados = 7 archivos en filesystem.

Conclusión Item 3: el server ya puede compilar assets. El siguiente paso natural (mover public/build/ a .gitignore + agregar npm ci && npm run build al runbook de deploy) lo difiero hasta resolver la divergencia master/feature para evitar acumular commits que compliquen el merge.

2026-05-18 — resolución de divergencia + deploy de cascada + commit del script

Pidió Sergio: resolver la divergencia master/feature una vez que terminó la coordinación con el otro agente.

Qué dejó el otro agente (verificado con git fetch --all --prune):

  • Merge commit 6fda261 Merge branch 'feature/exportar-csv-uisp' en origin/master.
  • Branch remota feature/exportar-csv-uisp borrada (clean cleanup).
  • 0 migraciones en el merge — solo código (cascada + comando es:exportar-csv-uisp ya estaban en commits previos).

Ejecutado en 3 fases:

Fase 1 — local: commit d07540a scripts: deploy.sh para automatizar despliegues a monitoreo (1 archivo, +148 líneas) pusheado a origin/master. Se aprovechó este deploy para meter el script al server junto con el resto.

Fase 2 — server (monitoreo):

cd /var/www/es-monitoreo
rm scripts/deploy.sh                    # borra copia untracked stale del scp previo
git checkout master                     # de feature/exportar-csv-uisp a master
git pull --ff-only origin master        # baja a9e1566 + 6fda261 + d07540a (3 commits, 4 archivos)
git branch -d feature/exportar-csv-uisp # deleted (was d6c6e7d), cleanup local

Fase 3 — deploy manual completo (sin migraciones porque no hay nuevas):

composer install --no-dev --optimize-autoloader --no-interaction
npm ci && npm run build           # Vite 6.2.5, sw.mjs + 12 entries precache
php artisan optimize:clear        # cache/compiled/config/events/routes/views limpios
sudo /usr/bin/php artisan horizon:terminate   # 20 procs terminated, systemd respawnea

Verificación post-deploy:

  • git log -1d07540a
  • 20 workers systemd activos (laravel-worker@1..20.service) ✓
  • 61 procesos horizon corriendo (20 masters + 20 supervisors + 21 workers) ✓
  • App\Console\Commands\NotificarLogs::despacharPendientes existe vía reflection ✓
  • php artisan list | grep es:exportares:exportar-csv-uisp registrado ✓
  • scripts/deploy.sh en server, ejecutable ✓
  • Detalle: php artisan horizon:status reporta inactive (mismo ruido del 2026-05-14; los workers via systemd no se registran como horizon master). No bloqueante — la operación normal usa los workers de systemd.

Lo que falta observar:

  • Próximo ciclo de alertas/recuperaciones (con la cascada nueva en producción) — verificar que cuando 2+ buckets caen en el mismo sitio, sale 1 correo a nivel sitio.
  • Primer uso real de scripts/deploy.sh en el SIGUIENTE deploy.

2026-05-18 — script de deploy automatizado

Pidió Sergio: automatizar los deploys en producción.

Decisiones tomadas (sin pausar):

  • Bash + SSH, no Envoy/GH Actions. El target es un único host accesible vía ssh monitoreo; un script de ~140 líneas en el repo cubre el caso. Sin secretos en GitHub, sin runners, sin webhooks.
  • El script vive en el repo, no en el hub. scripts/deploy.sh junto a dev-server-router.php. Beneficio: el deploy procedure queda checked-in y revisable por cualquier futuro contributor.
  • No commiteado todavía. El working tree del repo es-antenas-new local quedó con scripts/deploy.sh untracked. Commit + push esperan hasta que se resuelva la divergencia master/feature en el server — agregar más commits a master ahora solo complica el merge con feature/exportar-csv-uisp del otro agente. Misma razón que ya bloquea el item de .gitignore para public/build/.
  • Migraciones on-by-default, con --skip-migrations como escape. El historial del proyecto muestra que casi todo deploy reciente incluye migraciones (4 columnas flapping, origen/uisp_id/administrado); requerir flag para correrlas sería fricción.
  • Sin auto-rollback. El script imprime el SHA previo y el comando exacto de rollback en su log final. Ejecutarlo es decisión humana porque puede haber migraciones aplicadas que requieran reversión manual.

Diseño del script (scripts/deploy.sh):

  • Preflight: cwd = /var/www/es-monitoreo, rama = master, working tree limpio (ignora mods en storage/*/.gitignore que Laravel auto-regenera), git fetch y reporta count de commits pendientes. Aborta limpio si algo falla.
  • Deploy: git pull --ff-onlycomposer install --no-dev -o --no-interactionnpm ci && npm run buildphp artisan migrate --forcephp artisan optimize:clearsudo /usr/bin/php artisan horizon:terminate. Las pausas/retries que estaban implícitas en el runbook quedaron formalizadas.
  • Postflight: espera 5s, valida horizon:status reporta running, cuenta workers activos por systemd (laravel-worker@*.service), warnea si algo no encaja pero no falla (el deploy ya pasó).
  • Logging: append a storage/logs/deploys.log con timestamp ISO, SHA antes/después, duración, flags usadas. Útil para auditar y para que el próximo agente vea historia de despliegues sin tener que correlacionar bitácora.
  • Flags: --dry-run (imprime los pasos sin mutar), --skip-build (hotfix sin assets), --skip-migrations, --allow-dirty (escape hatch), --help.

Verificación:

  1. bash -n scripts/deploy.sh → syntax OK.
  2. Subido a server temporalmente vía scp; ./scripts/deploy.sh --dry-run en server:
    [...] === deploy es-antenas-new (dry_run=1 skip_build=0 skip_migrations=0) ===
    [...] cwd=/var/www/es-monitoreo
    [...] ERROR: rama actual 'feature/exportar-csv-uisp' != 'master'. Cambia con: git checkout master
    El preflight detectó la divergencia y abortó. Comportamiento esperado.
  3. --help funciona limpio (un fix menor al parser de help después del primer dry-run).

Lo que NO toqué (autorización requerida):

  • No commiteé en el repo local. Working tree tiene scripts/deploy.sh untracked.
  • No re-subí la versión final al server (classifier bloqueó scp post-fix-cosmético; la versión del server tiene help con 1 línea extra pero la lógica del deploy es idéntica). Para re-subir, Sergio puede correr: scp ~/code/es-antenas-new/scripts/deploy.sh monitoreo:/var/www/es-monitoreo/scripts/deploy.sh && ssh monitoreo chmod +x /var/www/es-monitoreo/scripts/deploy.sh. O esperar a que el script se commitee + el siguiente deploy lo deje en su lugar natural.

Camino feliz post-divergencia:

# en local
cd ~/code/es-antenas-new
git add scripts/deploy.sh
git commit -m "scripts: deploy.sh para automatizar despliegues a monitoreo"
git push origin master
# en server, una vez (o resubir vía scp después del merge):
ssh monitoreo "cd /var/www/es-monitoreo && git pull --ff-only && chmod +x scripts/deploy.sh"
# de aquí en adelante:
ssh monitoreo "/var/www/es-monitoreo/scripts/deploy.sh"

2026-05-08

  • Pidió Sergio: registrar el proyecto.
  • Hice: archivo creado, stack documentado, regla de idioma del agente capturada (español para UI/variables/comentarios, inglés para respuestas al usuario).
  • Falta: Sergio dictará pendientes específicos cuando los tenga.

2026-05-08 — pendientes

  • Pidió Sergio: validar que los correos se mandan correctamente; le da seguimiento el lunes 2026-05-11.
  • Confirmado: el alias “monitoreo” = es-antenas-new (no monitoreo-collector).
  • Falta: aclarar alcance de “validar correos” antes de actuar.

2026-05-11 — sesión grande: arreglar push + reformar correos urgentes

Pidió Sergio: revisar el pendiente de push notifications y, sobre la marcha, revisar a fondo cómo se mandan los correos del sistema (estaba recibiendo demasiados). Salió un trabajo grande con 5 fases (A, B, C(ii), D, Extras) más un hotfix de Blade.

Servidor de prod: monitoreo (192.168.20.17) → /var/www/es-monitoreo/. Repo: electrosystems-mx/es-antenas-new, rama master. No tiene Node/npm instalado — los assets se compilan local y se commitean. Capturado como tarea aparte arriba.

Fix de push notifications (root cause encontrado). El push fallaba silenciosamente — los correos sí se mandaban pero los push no llegaban al device. El log de prod contenía la pista exacta:

"error":"It is highly recommended to install the GMP or BCMath
 extension to speed up calculations..."

La librería phpseclib3 (que firma los headers VAPID con ECDH) emite un E_USER_NOTICE cuando no encuentra GMP ni BCMath; el handler de producción de Laravel lo convierte en ErrorException y el try/catch de NotificarLogs lo registra como “Error enviando push” — pero el push nunca se envía. Esto explicaba también por qué failed_jobs estaba vacío.

  • Acción: instalar php8.2-gmp en el server (sudo apt-get install -y php8.2-gmp) y recargar php8.2-fpm. Sergio lo ejecutó vía sudo (mi cuenta solo tiene NOPASSWD para mysql y horizon:terminate).
  • Verificación: después de la instalación los errores cesaron y el siguiente push se entregó al device de prueba.
  • Bonus: durante el diagnóstico encontré que storage/logs/laravel.log estaba en 32 GB (sin rotar). Truncado a 0 y cambiado LOG_CHANNEL=stackdaily en .env para que ahora rote a laravel-YYYY-MM-DD.log con retención de 14 días.

A — Mimosa Capacity urgente_si_cero=false. Identificada una segunda causa del ruido: los 4 monitoreos de capacidad Mimosa (ids 91, 96, 126, 127 → RX/TX Capacity Mimosa y RX/TX Capacity Mimosa C5c) tenían urgente_si_cero=true, así que cuando la antena reportaba 0 Mbps (falso cero común en Mimosa) la falla escalaba de tier normal a urgente y se enviaba como [ALERTA] inmediata en lugar de esperar al digest. Flipeé el flag a false en los 4 vía tinker directo en prod. Cambio en DB únicamente, sin código.

B — Detección de flapping (commit 058a22b). Cuando un monitoreo_dispositivo abre N=3 rachas en M=15 min, se marca como flapping y:

  • Se suprimen los correos normales (alerta inicial, recordatorio).
  • Se manda un solo correo “Flapping detectado”.
  • Mientras siga flappeando, recordatorio cada 24h.
  • Cuando esté estable por recovery_minutos=30 ininterrumpidos, se manda “Flapping resuelto” y se reanuda la cadencia normal.
  • 4 columnas nuevas en estatus_monitoreos_dispositivos: flapping_desde, flapping_notificado_at, flapping_estable_desde, transiciones_recientes (JSON con timestamps de la ventana móvil).
  • Constantes en config/monitoreo.php (flapping.n_transiciones, flapping.ventana_minutos, flapping.recovery_minutos, flapping.recordatorio_horas); todas overridables por .env.

C(ii) — Diferir urgentes durante quiet hours + digest al amanecer (commit 7a01a66). El objetivo de Sergio: “informative emails when quiet-hours end. And during non-quiet hours, not be bombarded.”

  • Helper nuevo App\Support\QuietHours con estaActivo(): bool (maneja el wraparound 22:00 → 07:00 cruzando medianoche).
  • NotificarLogs ahora hace early-return cuando está dentro de quiet hours; no envía nada.
  • Nuevo comando es:notificar-urgente-digest agendado a quiet_hours_end (07:00 por default). Recolecta TODO lo urgente pendiente (alertas iniciales, recordatorios, recuperaciones, flapping detectado/recordatorio/estable) y manda un solo correo consolidado agrupado por sitio. Después marca los timestamps para que NotificarLogs reanude su cadencia normal sin re-emitir.

D — Override de tier a nivel dispositivo (commit 8bd832c). Sergio pidió poder marcar dispositivos enteros como tier normal (para dispositivos no críticos cuyo ruido no le importa). Implementado como override que gana en ambos sentidos:

  • dispositivos.tier_default enum nullable ('urgente' | 'normal').
  • En EstatusMonitoreoDispositivo::resolverTier() se hace short-circuit a ese valor antes de revisar la prioridad por monitoreo. Si está set, ignora urgente_si_cero y limite_urgente.
  • UI: dropdown en Dispositivos/Forma.vue con tres opciones (Automática / Siempre urgente / Siempre normal).

Extras — Skip throughput monitors (commit 46ce916). Sergio: “some monitoreos shouldn’t trigger notifications (like RX throughput, which are the ones usually without a limit)”. La columna monitoreo.tiene_limite (que ya existía) marca exactamente eso: 78 de 165 monitoreos lo tienen en false (todos los RX, TX, Port N Throughput). Estos generaban falso ruido cuando un dispositivo se caía y reportaba 0 bps.

  • Scope notificable agregado a MonitoreoDispositivo que combina ignorar=false + monitoreo.tiene_limite=true.
  • Reemplazado en TODAS las queries de notificación (NotificarLogs ×6, NotificarDigestNormal ×3, NotificarUrgenteDigest ×6).
  • No requirió cambio para los custom labels: la columna monitoreos_plantillas.etiqueta y monitoreos_dispositivos.etiqueta ya existían, y el accessor MonitoreoDispositivo::getEtiquetaAttribute() ya hacía la precedencia correcta (override device → pivot plantilla → nombre canónico). Spot-check en prod: AP 911 resuelve “1 RX” → “RX”, “TX Capacity Mimosa C5c” → “TX Capacity”, etc. Cobertura actual: 107/283 pivots de plantilla tienen etiqueta; cuando un correo muestre el nombre canónico, es porque ese pivot está vacío — se llena desde la UI de Plantillas.

Hotfix de Blade (commit d0dc741). Durante el deploy de B, el digest normal de las 21:00 falló con syntax error, unexpected token "use", expecting "elseif" or "else" or "endif" en digest_normal_markdown.blade.php. Causa: el componente <x-mail::message> envuelve el slot en una closure, y un @php use App\Foo; @endphp dentro queda como use fuera del top de archivo → PHP parse error. Funcionaba antes porque la vista estaba compilada en cache; al recompilar tras optimize:clear, falló. Fix: reemplazar use ...; + nombre corto por nombre fully-qualified \App\Support\MonitoreoCorreo::.... Aplicado a las tres plantillas (digest_normal, digest_urgente_quiet_hours, flapping).

Resumen de tests: 180 tests pasan (de 181), 1 fallo preexistente (SnmpSimuladorTest) no relacionado. Tests nuevos:

  • FlappingDetectionTest (8)
  • QuietHoursDigestTest (6)
  • DispositivoTierDefaultTest (7)
  • MonitoreoSinLimiteSkipTest (5)

Lo que queda observar (próximos 2-3 días):

  • Que el digest de las 07:00 de mañana llegue limpio y agrupado.
  • Que entre 22:00 y 07:00 no llegue ningún correo urgente.
  • Que la detección de flapping atrape el patrón que se vio en Caborca el 2026-05-11 sin generar falsos positivos.
  • Que ya no lleguen correos de monitoreos throughput (RX/TX) por antenas caídas.

2026-05-14 — fix de recordatorios apilados (vecinos en el bucket)

Pidió Sergio: revisar 4 capturas de Gmail con [FLAPPING RECORDATORIO] apilados — ~20 correos de Caborca y Terreón, mayoría con “1 métrica” en el asunto. Sospecha de fan-out.

Diagnóstico:

  • Correos 1 y 2: mismo dispositivo AP CU-Esquiveles AF11 (Torreón, IP 192.168.13.152), mismo flapping_desde (13/05 01:00), RX y TX separados 19 min en el inbox. Confirma desincronización de flapping_notificado_at.
  • Correos 3 y 4: dos dispositivos del mismo sitio Caborca (AP La Gloria-911 B5c + Netonix 911), separados 3 min. Confirma que la promoción a sitio del agrupador no se activa entre buckets distintos.
  • Correo 4 (Netonix 911 con 6 métricas) confirma que el agrupador a nivel dispositivo sí funciona cuando las métricas coinciden en el mismo tick.

Causa raíz: en NotificarLogs::procesarFlapping, el bucket de recordatorios se cose por tick (flapping_notificado_at <= now - 24h). Los estatuses del mismo dispositivo/sitio quedan desfasados por minutos desde el correo inicial (cada métrica entra a flapping en su propio tick), así que cada tick procesa una rebanada pequeña. El agrupador agrupa lo que recibe, pero recibe poco.

Implementación (NotificarLogs.php:185-244):

  1. Query “seeds” con el criterio original (flapping_notificado_at <= now - 24h).
  2. Obtener sitio_ids de los dispositivos de los seeds (excluyendo NULL).
  3. Cargar TODAS las métricas en flapping no-recuperadas de esos sitios; fallback por dispositivo_id para dispositivos sin sitio.
  4. Pasar el conjunto completo al agrupador → un correo por dispositivo/enlace/sitio.
  5. UPDATE de flapping_notificado_at = now cubre todo el grupo → resincroniza para el próximo ciclo.

Trade-off: una métrica que entró a flapping hace 30 min recibirá su “recordatorio” antes de las 24h, pero llega agrupada con sus hermanas en un correo que Sergio ya quería ver. No es ruido adicional, es la consolidación pedida.

Tests (tests/Feature/FlappingDetectionTest.php): +3 tests nuevos:

  • “recordatorio: métricas del mismo dispositivo con flapping_notificado_at desincronizado se agrupan en 1 correo” — replica el caso AP CU-Esquiveles.
  • “recordatorio: dispositivos distintos del mismo sitio se promueven a 1 correo de sitio” — replica el caso Caborca.
  • “recordatorio: sin seeds vencidos no se manda nada” — sanidad para evitar arrastre prematuro.

Suite: 192/192 passing (era 189; +3 nuevos). ./vendor/bin/sail test, 29.76 s.

Deploy ejecutado 2026-05-14 (~14:22 hora server):

  • Commit 616d7fc fix: drag neighbors of same device/site into flapping reminder bucket pusheado a origin/master.
  • git pull --ff-only en monitoreo: 8e8023b..616d7fc, 2 archivos, +147/-6.
  • composer dump-autoload -o --no-dev: 6118 clases regeneradas.
  • php artisan optimize:clear: cache/compiled/config/events/routes/views limpiados.
  • sudo /usr/bin/php artisan horizon:terminate: nuevos workers arrancaron a las 14:22 (PIDs 841xxx con supervisores 1yyZ, DjsX, IYNi).
  • Verificación: class_exists("App\\Console\\Commands\\NotificarLogs")true.

Observación menor (no bloqueante): horizon:status reporta inactive, pero hay 4+ workers nuevos corriendo el código nuevo. Causa: hay 33 procesos artisan horizon huérfanos del 2026-05-12 acumulados (parent PID 1, supervisores kQsd, WuFT, etc.). Cada deploy deja master procs colgados. No afecta operación, pero ensucia diagnóstico y acumula memoria. Backlog separado.

Lo que falta observar:

  • Próximo ciclo de recordatorios (mañana ~07:00) — esperar 1 correo por dispositivo cuando varias métricas vencen, 1 correo por sitio cuando varios dispositivos vencen.
  • queue:failed CLI sigue roto por el bug de permisos del log (preexistente, ya documentado).

2026-05-12 — agrupar correos de flapping + suprimir “RESUELTO” en vivo

Pidió Sergio: revisar por qué siguen llegando demasiados correos [FLAPPING RESUELTO] — captura de Gmail mostró ~15 correos del sitio Caborca en 2 min (3 APs × ~5 métricas c/u). El flapping detection funcionaba pero opera a nivel monitoreo_dispositivo (cada métrica), no a nivel dispositivo/enlace/sitio. Pidió agrupar también por enlace; en la conversación se extendió a sitio cuando varios enlaces caen juntos.

Reglas de agrupación acordadas (cascada):

  1. Por dispositivo: todas las métricas en flapping del mismo dispositivo_id → 1 ítem.
  2. Promover a enlace solo si todos los dispositivos del enlace están en el grupo pendiente (ambos extremos de un PtP). Si solo un extremo flappea, se queda como dispositivo. Si el dispositivo está en varios enlaces y solo él flappea, se queda como dispositivo.
  3. Promover a sitio si en un mismo sitio_id quedan ≥2 ítems del paso 2 (sean enlaces o dispositivos sueltos).
  4. Lo demás sale como enlace o dispositivo según corresponda.

Lever 2 (juntada en el mismo PR): el correo “RESUELTO” en vivo se suprime; se acumula y aparece en el digest urgente de las 07:00 como nuevo bloque ”🟢 Estabilizados durante quiet hours”. Razón: un equipo que se recupera no es accionable en el momento y duplica el ruido de la detección.

Implementación:

  • Nuevo helper App\Support\FlappingAgrupador con agrupar(Collection $estatuses): Collection que ejecuta la cascada y devuelve grupos uniformes {scope, sitio, enlaces[], dispositivos_sueltos[], estatuses_planos} — la misma forma para los 3 niveles, el Blade no necesita lógica condicional.
  • NotificarLogs::procesarFlapping refactor: query → agrupar → un correo por grupo → marcar flapping_notificado_at masivo. El bloque “estables” desaparece de aquí.
  • NotificacionFlapping recibe ahora un grupo en lugar de un solo EstatusMonitoreoDispositivo. Asunto muestra {sitio} · N enlaces · M métricas · dd/mm HH:MM. Se eliminó TIPO_ESTABLE.
  • Plantilla de flapping reescrita para renderizar la jerarquía (encabezado por scope, luego enlaces → dispositivos → métricas en líneas anidadas).
  • NotificarUrgenteDigest ya recogía flappingEstables; ahora el Blade del digest los pasa por el mismo agrupador y los renderiza en su propia sección “Estabilizados durante quiet hours”.

Tests (./vendor/bin/sail test): 189/189 pasan. Anotaciones:

  • 7 tests nuevos en FlappingDetectionTest: agrupación dispositivo / enlace / “solo un extremo no promueve” / “dispositivo en varios enlaces no promueve” / sitio con 3 enlaces / sitio mixto (enlace + dispositivo suelto) / e2e tipo Caborca (3 enlaces × 2 dispositivos × 5 métricas = 30 estatuses → 1 correo).
  • 1 test nuevo en QuietHoursDigestTest: el digest matutino incluye estables acumulados y limpia su estado.
  • Test existente “envía estable” se renombró a “ya no envía estable en vivo”.
  • El SnmpSimuladorTest que estaba en fallo preexistente (180/181) hoy pasó verde sin tocarlo.

Lo que faltó observar (después del deploy):

  • Confirmar en correo real que un AP con 5 métricas flappeando genera 1 solo correo “dispositivo” (no 5).
  • Confirmar que cuando ambos extremos de un PtP flappean, el correo lo reporta como enlace.
  • Confirmar que cuando varios enlaces de un sitio flappean (caso Caborca), el correo lo reporta como sitio.
  • Confirmar que entre flap y siguiente 07:00 no llega ningún correo “RESUELTO” en vivo, y que el digest matutino lo agrupa.

Pendiente derivado (no aplicado aquí):

  • Las alertas normales (procesarAlertas/procesarRecuperaciones en NotificarLogs) ya agrupan por enlace pero no por sitio. Aplicaría la misma cascada del agrupador para consistencia — backlog cuando confirmemos el caso de flapping.

Deploy 2026-05-12 ~14:14

  • Commit: 8e8023b feat: group flapping emails by device/link/site and defer RESUELTO to morning digest. Pusheado a origin/master.
  • Detalle inesperado: el server estaba en 5b6b21a, dos commits atrás. La traída de git pull subió dos commits: 46ce916 (Extras del 2026-05-11, “skip non-threshold monitors”) + 8e8023b (hoy). La bitácora de May 11 marcaba 46ce916 como aplicado pero en realidad estaba commiteado en GitHub sin desplegarse al server hasta hoy. Sin impacto adverso (Sergio lo validó con tests entonces); solo se confirma con esta nota que el throughput-skip ya está en producción a partir de hoy.
  • Pasos ejecutados en monitoreo (192.168.20.17, vía ssh monitoreo):
    1. git pull --ff-only origin master — 11 archivos cambiados (incluye 46ce916 y 8e8023b).
    2. composer dump-autoload -o --no-dev — el autoload optimizado recoge App\Support\FlappingAgrupador.
    3. php artisan optimize:clear — limpió cache/compiled/config/events/routes/views (incl. Blade compiladas de las 2 plantillas que cambiaron).
    4. sudo /usr/bin/php artisan horizon:terminate (NOPASSWD permitido) — el supervisor levantó nuevos workers de inmediato; al momento de verificación había 46 procesos horizon:work corriendo el código nuevo.
  • Verificaciones:
    • php artisan tinker → class_exists("App\\Support\\FlappingAgrupador")OK.
    • php artisan horizon:statusHorizon is running.
    • queue:failed solo reporta failures viejos del 2026-04-14 (ProcesarAgregadosSitio); ninguno del 2026-05-12 → el deploy no rompió jobs ni dejó mailables de la firma vieja atorados.
  • Bug pre-existente desenterrado: el log de Laravel de hoy (storage/logs/laravel-2026-05-12.log) tiene permisos www-data:www-data 644, así que el usuario electrosystems no puede agregarle líneas desde CLI. Cualquier php artisan ... corrido por mí escupe error al intentar loguear (PHP-FPM detrás de nginx sigue escribiendo sin problema). No relacionado con este cambio; capturado en tareas pendientes.