Hub

2026-05-21

jueves · 21 de mayo de 2026

2026-05-21 — bitácora del día

gi-calzada — routing cross-VLAN red plana ↔ teléfonos (resuelto)

Pidió Sergio: retomar diagnóstico del día anterior. Server gi-calzada (sucursal Calzada de Grupo Imperial): equipos en red plana 172.16.87.0/24 no veían nada de la VLAN teléfonos 172.16.187.0/24, ni al server por su .187.254. gi-corp (172.16.0.0/22) tampoco veía .187. El server es gateway de teléfonos pero no de la red plana (ésa la maneja un switch cisco en .87.199). OSPF: solo entre equipos del cliente, no en este server.

Diagnóstico (SSH read-only):

  • eth0 .187.254/24, eth1 .87.11/24 (+ alias legacy 192.168.1.5), default gw el cisco.
  • Tres hallazgos: ip_forward=0, sin daemons FRR/quagga/zebra, iptables FORWARD policy ACCEPT sin reglas.
  • tcpdump -i eth1 'dst net 172.16.187.0/24' durante 5s → 0 paquetes.

Pivote clave (Sergio): el cisco también es L3 en la VLAN teléfonos (172.16.187.1, confirmado por mi ARP en eth0). El problema NO era falta de ruta — era forwarding.

Causa raíz: routing asimétrico. .87 → .187 va directa por el cisco (que tiene SVI en ambas VLANs). La vuelta .187 → .87 pasa por el server como gateway de los teléfonos → kernel la dropea por ip_forward=0.

Fix: sysctl -w net.ipv4.ip_forward=1 + persistido en /etc/sysctl.conf. Lo aplicó Selene manualmente.

Validación:

  • /proc/sys/net/ipv4/ip_forward = 1.
  • Persistido en /etc/sysctl.conf (sobrevive reboot).
  • iptables FORWARD chain pasó de 0 paq / 0 B a 367 paq / 99 KB en minutos → kernel reenviando confirmado.

Memorias nuevas:

  • [[reference-cisco-svi-dual-vlan-needs-ip-forward]] — patrón reusable cross-cliente.
  • [[reference-gi-calzada-server]] — info técnica del server.

Detalle completo: projects/gi-calzada.md.

electro-ia — Fase 2.11 dashboard de uso comparativo (cerrada)

Pidió Sergio: seguir avanzando con electro-ia tras 2.10. Recomendación dada: reportes de uso comparativo Antigravity API vs local (Fase 2.7 abierta), porque los datos ya estaban persistidos en tool_call_log.model + api_spend y la decisión de default_backend no se podía cerrar sin ver números. Aceptó.

Hallazgo de entrada: el dashboard SvelteKit aspiracional del README nunca se migró desde projects-hub. Solo había 3 endpoints HTTP loopback. Sergio eligió opción A (endpoints JSON + HTML vanilla, ~3-4h) sobre B (SvelteKit full).

Implementación (commit 08b0b70 en laptop-ia, sin remote):

  • src/db.js: 6 queries de stats con filtro since (default 1ro del mes en MDT). daily genera 14 días con generate_series para incluir gaps.
  • src/index.js: rutas /api/stats/{summary,by_backend,by_model,by_tool,by_user,daily} + servidor estático mínimo en /dashboard. Todo loopback-only.
  • web/dashboard.html + web/dashboard.js: vanilla, dark mode, sin Chart.js (barras manuales con div widths para evitar dependencia CDN).

Smokes ✅: loopback gate 403, dashboard HTML 200, todos los endpoints devuelven JSON correcto. Datos reales del mes: $0.43/$100 spend, cache hit 63%, 96 out local vs 15 API, p95 latency tools api=1587ms vs local=735ms.

Bug in-flight fixeado: SQL devolvía day como timestamp completo, JS esperaba YYYY-MM-DD. Fix con .slice(0, 10) en JS (no requirió restart porque el JS se sirve cada request sin cache).

Acceso del jefe: ssh -L 8080:127.0.0.1:8080 electroia@192.168.3.99 + browser a http://localhost:8080/dashboard. Para acceso sin tunnel hay que decidir auth real.

Cambio operacional adicional: Sergio agregó /etc/sudoers.d/electroia-self-restart con NOPASSWD limitado a systemctl restart/reload/status electro-ia. Anula la nota previa de "restart requiere Sergio". Ahora Antigravity puede reiniciar el bot en sesiones futuras vía SSH como electroia.

Detalle completo: projects/electro-ia/README.md bitácora 2026-05-21.

electro-ia — Fase 2.12 lectura de DBs remotas (amadeus primero) + cambio default_backend=api

Pidió Sergio: pendiente que tenía sin mencionar — "que el bot pueda leer bases de datos de servidores". Primer target: amadeus (viáticos) porque el jefe quiere empezar consultas ahí.

Decisiones (6 preguntas cerradas con Sergio antes de codear):

  1. Scope: TODAS las DBs de Electrosystems (sin las personales — aprende-ingles, medicinas fuera). amadeus primero.
  2. Creds: yaml declarativo + fallback SSH-extract a futuro.
  3. Read-only: validador SQL en código.
  4. Roles: por DB en policy/databases.yaml (allowed_roles).
  5. Conexión: ssh <host> 'mysql --batch -e ...' (sin tunnel, reusa patrón de probe-pbx-db.sh).
  6. User MySQL en amadeus: dedicado electroia_ro con GRANT SELECT only (defensa en profundidad).
  7. Schema hints al modelo: tools de exploración (db_list_tables + db_describe_table) + domain_hint curado por DB.

Setup del lado de Sergio (lo que requería root/secrets):

  • En amadeus: CREATE USER electroia_ro@localhost ...; GRANT SELECT ON amadeus.* .... Validado: GRANTS solo SELECT, CREATE/INSERT rechazado por MySQL.
  • En laptop-ia .env del bot: AMADEUS_DB_USER + AMADEUS_DB_PASSWORD.
  • En /home/electroia/.ssh/config: bloque Host amadeus (HostName 192.168.20.20, User electrosystems, IdentityFile id_rsa_es). Sergio cambió ownership a electroia para futuras ediciones.

Implementación (commit 4caba1b en laptop-ia):

  • src/sql_validate.js (nuevo) — parser defensivo (strip comments → first-keyword allowlist SELECT/SHOW/DESCRIBE/EXPLAIN/WITH → reject DML/DDL keywords / ; no-final / INTO OUTFILE).
  • src/db_runner.js (nuevo) — ssh + mysql --batch -u<user> -D<db> con SQL por stdin (evita escape hell) y pwd vía MYSQL_PWD env del shell remoto (no en argv).
  • 4 tools nuevas en src/tools/: db_list_databases (filtra por rol), db_list_tables (con TABLE_ROWS approx + size_mb), db_describe_table (15 cols con tipo/null/default/key/comment), db_query (cap 100 rows por default, configurable por DB).
  • policy/databases.yaml — solo amadeus por ahora. domain_hint corregido in-flight tras descubrir que el schema es en español (usuarios/viajes/viaje_compras, no users/viaticos).
  • prompts/system.md con 2 reglas inviolables nuevas: no inventes schema sin describe, no intentes DML.

Smoke unitario 10/10 contra amadeus prod:

  • list_databases (admin) → 1 DB; (viewer) → 0 DBs ✅
  • list_tables(amadeus) → 47 tablas reales ✅
  • describe(viajes) → 15 cols (folio, fecha_inicio, fecha_fin, dias_servicio, ...) ✅
  • query COUNT viajes → 187 ✅
  • query SELECT últimos 3 viajes → folios BJZ20260519, BYN20260511 ✅
  • UPDATE/DELETE/INSERT → sql_not_readonly
  • ; DROP TABLEsql_multi_statement
  • INTO OUTFILEsql_into_file
  • DB no en allowlist (holbox) y viewer en amadeus → forbidden_database

Decisión adicional: DEFAULT_BACKEND=api. Sergio cambió de criterio vs decisión previa "local" — "el api es infinitamente mejor que el local". Proyección a 15 usuarios × ritmo Sergio = ~$6.50/mes (cap $100, sin riesgo). .env editado, restart con sudoers.

Detalle completo: projects/electro-ia/README.md bitácora 2026-05-21 (noche).

es-antenas-new — sesión 3-en-uno: limpieza + SNMP Fase 1 + SNMP Fase 2 RFC

Pidió Sergio: "con qué avanzamos en es-antenas-new". Tres bloques en una sesión:

Bloque 1 — limpieza + bug menor (commit 57796ba)

  1. /public/build a .gitignore + git rm -r --cached de 7 archivos. Vite produce assets byte-determinísticos (validado el 2026-05-18), versionarlos solo agregaba ruido al diff. El deploy regenera vía npm ci && npm run build (que ya estaba en scripts/deploy.sh).
  2. Bug del conteo de workers en postflight de scripts/deploy.sh: el grep estaba bien (manualmente regresa 20), root cause era timing — RestartSec=5 en laravel-worker@.service + boot de horizon excedía el sleep 5 fijo. Reemplazado por polling de 30s con paso de 2s.

Deploy del bloque: 117s, OK. El postflight ejecutado fue el VIEJO (el shell ya tenía cargado el script en memoria antes del git pull); aun así reportó 20 — horizon:status añadió segundos de buffer. El próximo deploy estrenará el polling nuevo.

Bloque 2 — SNMP Fase 1: fix puntual Rumurachi-Urique

Fase 1 acotada del problema arquitectural del ifIndex no persistente (detonante 2026-05-20). Investigación previa:

  • 77 monitoreos hardcodean ifIndex en llave_valor (15 ifTable + 62 ifXTable). Idx varían 1-15, 25, 2001/2005 (MikroTik VLANs), 1000010+ (EdgeRouter bonding).
  • Monitoreo 97 (ifSpeed.3) está asignado a 7 dispositivos: 5 Mimosa B11, 1 Ubiquiti AF11, 1 Cambium 4600C (el roto = dispositivo 206). Borrar el monitoreo entero rompería los otros 6.
  • MonitoreoDispositivo usa SoftDeletes; el endpoint del collector usa Eloquent → soft-delete sí silencia el polling. ignorar=1 solo silencia correos, no detiene la lectura.

Mutaciones quirúrgicas autorizadas y ejecutadas vía php artisan tinker en server:

  1. Soft-delete de monitoreos_dispositivos.id=989 (monitoreo 97 en dispositivo 206). El primer intento con delete() raw falló por FK con lecturas_agregadas; soft-delete (->delete() del modelo SoftDeletes) sí pasa porque solo hace UPDATE deleted_at = NOW().
  2. DELETE FROM monitoreos_plantillas WHERE monitoreo_id=97 AND plantilla_id=20 (preventivo — Cambium 4600C ya no hereda el monitoreo a 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 daba por activo en realidad estaba soft-deleted desde 2026-05-04 — el único OID roto era el 97.

Bloque 3 — SNMP Fase 2 RFC (sin código)

Decisiones de diseño consensuadas con Sergio para resolver el problema arquitectural (77 monitoreos hardcodean ifIndex). Exploración previa del monitoreo-collector (Go) reveló: poll batch single-call con error global (UN OID malo tumba el batch entero), sin tests en el repo, sin cache hoy en SQLite.

  • Sintaxis placeholder: {IF} en monitoreos.llave_valor (ej. .1.3.6.1.2.1.31.1.1.1.6.{IF}).
  • Schema: columna nueva monitoreos_dispositivos.interfaz VARCHAR(64) NULL (ej. "LAN1", "ether3").
  • Source-of-truth: ifDescr con fallback ifName.
  • Cache: tabla SQLite nueva interface_mappings en el collector con TTL 1h; invalidación on noSuchInstance.
  • Error semantic: "falla explícita con razón" via campo nuevo razon_falla en LecturaCombinadaDetalle. UI muestra "Interfaz LAN1 no encontrada" en vez de 0 silencioso.
  • Backward-compat: si OID sin {IF} o asignación sin interfaz → comportamiento actual. Migración convive con modelo viejo indefinidamente.
  • Fases siguientes: 3a = scaffold de tests del collector como sesión propia (testify + gosnmp mock + 4-5 tests del resolver), 3b = implementación end-to-end (migración Laravel + endpoint + collector + tests + deploy), 4 = migración de datos por lotes empezando con MikroTik (mayor fragilidad por VLANs idx 2001/2005).

Sin código en este bloque. Cero deploys. RFC documentado completo en projects/es-antenas-new.md bitácora 2026-05-21.

Commits del hub: 47bdb11 (limpieza), 3af89ba (Fase 1), 608aa2d (Fase 2 RFC). Commits del repo: 57796ba.

Detalle completo: projects/es-antenas-new.md bitácoras del 2026-05-21.

holbox — feature WhatsApp óptica + simulador privado (cerradas)

Sesión larga, 3 bloques encadenados.

Bloque 1 — WhatsApp Óptica (al cerrar venta con optometría)

Aarón pidió que cuando una venta lleva optometría, el cliente reciba 2 mensajes adicionales por WhatsApp: foto del local con texto (saludo + dirección + horarios) y un pin de ubicación. Ambos templates (optica_referencia_v1 + optica_ubicacion_v1, UTILITY/es_MX) ya estaban aprobados por Meta.

Implementación: extendido contrato WhatsAppSender + senders MetaCloud/Log + nuevo Job SendWhatsappOptica siguiendo el patrón existente; dispatch dentro del mismo bloque del ticket en PosController, condicionado a detalles->contains(tipo_optometria != null); 7 env vars nuevas; foto del local en public/img/optica-local.jpg. Idempotencia ante retry: 5xx burble en el primer template, 4xx silenciado; el segundo (LOCATION) tragamos excepciones para no duplicar.

Commit f023b51, deploy GHA 26251922592 verde. .env de prod editado vía ssh -t holbox sudo tee con las 7 vars. config:clear ejecutado como deploy. Foto accesible públicamente en https://holbox.val-soft.com/img/optica-local.jpg (200, 140 KB).

Bloque 2 — Simulador privado de ventas (/admin/simulador)

Sergio pidió poder disparar canales (WhatsApp + correo) en producción sin que las ventas queden en historial / inventario / reportes. Aprobé approach C: persistir en DB::beginTransaction y DB::rollBack en finally — efectos colaterales (HTTP Meta, SMTP) sí ocurren, BD queda limpia. Endpoint restringido a email == sevaor@gmail.com vía middleware SimuladorAccess; cualquier otro user ve 404. Sergio creó user admin con ese email en prod desde /usuarios/create.

Smoke test local 4/4 OK + rollback verificado (0 ventas SIM- en BD) + Mailpit recibió correo. Commit 5a4bfa3 + fix 6e7d445 (orderBy monto_requerido en lugar de orden inexistente), deploy 26257318534 verde.

Bloque 3 — Ajustes post-prueba

Sergio probó el simulador en prod y reportó:

  1. Orden de mensajes inconsistente → fix 6af4006 con usleep(800ms) entre los 2 templates del óptica y sleep(1) entre canales del simulador. Validado.
  2. "El mensaje de ubicación llega sin la ubicación" → endpoint de diagnóstico /admin/simulador/template/{nombre}?waba=... agregado (commits 5ec7472 + fb057a0). Debug temporal del payload (commit 52bcb06, revertido d727037). Causa raíz: Sergio leía en WhatsApp Web/Desktop que NO renderiza el header LOCATION — en móvil sí. Falso bug. Memoria de referencia guardada.
  3. "El pin abre otro lugar al hacer tap" → Aarón confirmó que el local aún no está en Google Maps. Mitigación: address extendida en .env prod a "Avenida de la Raza 7030, 32500 Cd. Juárez, Chih." Solución de fondo: pendiente #148 para Aarón (Google Business Profile).
  4. "Código de barras del correo no se renderiza bien y no se usa" → quitado de TicketVentaMail + blade. Commit d9717ef, deployado.

Memorias permanentes guardadas

  • reference_whatsapp_location_template_web.md — templates Meta con header LOCATION no renderizan el pin en WhatsApp Web/Desktop, solo en móvil. Verificar siempre en móvil antes de declarar bug del payload.
  • feedback_ssh_t_flag_for_user_paste.md — REGLA PERMANENTE: usar ssh -t en comandos que Sergio pega con ! cuando requieren sudo o prompt interactivo.

Otros ítems de la sesión

  • Diagnóstico TR-00023: reportado como "traslado no actualiza inventario al recibir", resultó ser un TR-00025 posterior que retrasladó los mismos productos a otra sucursal. Falso bug. Documentado.
  • 2 pendientes nuevos agregados al hub para próxima sesión: #136 (bono semanal de leaderboard en reporte de comisiones) y #137 (descuento 50% a ventas de empleados con autorización admin).
  • #148 (Aarón registra Holbox Óptica en Google Business Profile) — bloqueante real para que el pin del template optica_ubicacion_v1 caiga en el lugar correcto al hacer tap.

Commits del repo holbox (en orden): f023b51 (feature óptica), 5a4bfa3 (simulador), 6e7d445 (fix simulador), 6af4006 (orden mensajes), 5ec7472 (endpoint diagnóstico templates), fb057a0 (waba param), 52bcb06d727037 (debug temporal + cleanup), d9717ef (sin barcode email).

Detalle completo: projects/holbox.md bitácoras 2026-05-21 PM-2 → PM-5.


Cierre nocturno tardío — primera venta real validó óptica WA + #149 webhook status abierto

Aarón mandó captura del ticket público de la venta VTA2-000164 (MISIONES I, atendió Zoe, 20:47 local, total $2,700 con add-on Transition $2,000) y preguntó cómo se mandó el WhatsApp — la duda real era conceptual: "no le hace sentido que se manden mensajes de WhatsApp sin tener WhatsApp instalado".

Trazas en storage/logs/laravel.log de prod confirmaron que la venta (venta_id=590) disparó 4 mensajes WhatsApp en ráfaga, todos aceptados por Meta (cada uno con su wamid de respuesta):

  • 20:47:15 whatsapp.nivel_alcanzado.sent (BRONCE, primer nivel del cliente)
  • 20:47:16 whatsapp.ticket.sent
  • 20:47:17 whatsapp.optica.referencia.sent
  • 20:47:18 whatsapp.optica.ubicacion.sent

Feature óptica WA cerrado del lado del backend (PM-2 confirmado). Le mandé a Sergio (1) la captura del log lista para screenshot, (2) explicación conceptual de la API de Meta Cloud (no necesita WhatsApp instalado, equivalente a cómo Amazon/bancos mandan códigos), (3) ruta para verificar desde Meta Business Manager → WhatsApp Manager → Estadísticas.

Pendiente nuevo identificado: #149 — Webhook de status de Meta para WhatsApp. Hoy solo registramos sent (Meta aceptó); falta saber delivered / read / failed. La fricción quedó expuesta al pedir Aarón evidencia visual. Plan apuntado en projects/holbox.md: endpoint POST /whatsapp/webhook validando X-Hub-Signature-256 con META_APP_SECRET + tabla whatsapp_mensajes (PK=message_id, status enum, error_code) + persistir wamid en sender + handler que actualiza por wamid + UI admin con filtros + config en Meta Business → Configuration → Webhooks. Beneficio secundario: detectar bloqueos del cliente (error 131047) y auto-marcar whatsapp_opt_out=1. Bloqueado en parte por obtener META_APP_SECRET (de la app de Meta, no del System User token).

Commits del hub (en orden): 7146863 (cierre PM-2 feature óptica WA), 0fea89d (anotar #149 webhook). Sin cambios al repo de holbox en este bloque — solo documentación del hub.