feat(ui/polling): monitoreo de desarmadas en Grupo 0, tooltips explicativos y fecha compacta (v06.08.02)

- Monitoreo de Desarmadas & Grupo 0:^
This commit is contained in:
cheveguerra
2026-08-02 04:00:25 -06:00
parent ba2f9d642c
commit 36be1779c3
4 changed files with 247 additions and 35 deletions
+10 -3
View File
@@ -1,7 +1,7 @@
# Estado del Proyecto - TotalConnect 2.0
**Última actualización:** 2026-08-02
**Versión actual:** v06.08.01
**Versión actual:** v06.08.02
---
@@ -11,15 +11,20 @@ El proyecto **TotalConnect 2.0** es un panel web centralizado de alto rendimient
### 🛠️ Características Principales e Infraestructura
1. **Motor Híbrido Dinámico de Monitoreo (`index.py`)**:
- **Grupo 0**: Prioridad absoluta para sucursales con alarmas activas (particiones, zonas y estado ALARMING) o eventos SOAP pendientes (`needs_full_update = True`).
- **Grupo 0**: Prioridad absoluta para sucursales con alarmas activas, eventos SOAP pendientes y sucursales desarmadas bajo el modo *"Desarmadas revisión constante"*.
- **Control de Protección por Límite**: Si el número de sucursales desarmadas supera la variable `max_disarmed_count` configurada en `credentials.json`, la función se deshabilita automáticamente para evitar cuellos de botella y *rate-limiting*.
- **Esquema 2:1 (SOAP + REST)**: 2 consultas ligeras vía SOAP por 1 consulta completa por REST para evitar el *rate-limiting* de Honeywell (máximo 35 consultas REST por ciclo).
- **Resiliencia de Sesión**: Purgado automático de tokens SOAP obsoletos y auto-recuperación ante errores de red (`ConnectionResetError`).
2. **Dashboard Web e Interfaz en Tiempo Real**:
- **Switch "Desarmadas revisión constante"**: Control checkbox interactivo en la cabecera del dashboard con sincronización en vivo.
- **Tooltips Flotantes Explicativos**: Ventanas emergentes al pasar el cursor (`mouseover`) sobre los controles de la cabecera (*Cuentas*, *Desarmadas revisión constante* y *Polling: Activo*), detallando sus funciones y parámetros técnicos.
- **Formato Compacto de Fecha/Hora**: Fecha de última actualización optimizada con días/meses abreviados a 3 letras, sin año y hora en formato de 24 horas (ej. `dom, 2 ago - 02:36:18`).
- **Badge de Escaneo en Vivo**: Muestra en tiempo real la sucursal actual en proceso de carga/sondeo (`Cargando sucursal: 6001`).
- **Visor de Bitácora Interactiva**: Modal visual en vivo para consultar las últimas 150 líneas del archivo de log (`activity.log`).
- **Hard Reset**: Mecanismo de purga de caché y reinicio por clic prolongado (3s) en el botón de actualización.
- **Kill-Switch / Polling Toggle**: Control de inicio/pausa de sondeo en segundo plano con *Circuit Breaker* ante errores `401`/`429`.
3. **Portabilidad y Parámetros**:
- Persistencia de `monitor_disarmed_enabled` y `max_disarmed_count` en `credentials.json`.
- Eliminación de rutas absolutas en duro (`Z:\`, `E:\`), operando de manera 100% relativa a la ruta de ejecución actual.
- Binario autónomo `totalconnect.exe` para Windows y soporte para servicio Systemd en Linux.
@@ -27,11 +32,13 @@ El proyecto **TotalConnect 2.0** es un panel web centralizado de alto rendimient
## 🏷️ Historial de Tags / Versiones
* **`v06.08.02`**: Implementado switch "Desarmadas revisión constante" en UI con tooltip explicativo, persistencia en `credentials.json`, endpoint `/toggle-disarmed-monitor` y autodesactivación por límite `max_disarmed_count`.
* **`v06.08.01`**: Prioridad en Grupo 0 para alarmas y eventos SOAP, indicador de escaneo en vivo en UI, eliminación de rutas absolutas en duro.
---
## 📋 Tareas Pendientes / Próximos Pasos
- [ ] Monitorear estabilidad del motor híbrido en producción tras despliegue de `v06.08.01`.
- [ ] Monitorear estabilidad del motor híbrido y la función de desarmadas en producción tras despliegue de `v06.08.02`.
- [ ] Validar rendimiento en entornos con elevado número de cuentas Total Connect.
+223 -14
View File
@@ -177,7 +177,7 @@ CACHE_FILE = os.path.join(BASE_DIR, 'locations_cache.json')
LAST_STATE_FILE = os.path.join(BASE_DIR, 'last_state.json')
LAST_EVENT_IDS_FILE = os.path.join(BASE_DIR, 'last_event_ids.json')
LOG_FILE = os.path.join(BASE_DIR, 'activity.log')
APP_VERSION = "06.08.01"
APP_VERSION = "06.08.02"
polling_trigger_event = threading.Event()
soap_disabled_accounts = set()
soap_session_ids = {} # dict: username -> GUID SessionId para SOAP GetLiveEvents
@@ -228,7 +228,37 @@ def save_polling_state(enabled):
except Exception as e:
log_msg(f"⚠️ No se pudo actualizar polling_enabled en {CREDENTIALS_FILE}: {e}")
def load_disarmed_monitor_config():
"""Carga el estado del monitoreo de desarmadas y su límite máximo exclusivamente desde credentials.json."""
enabled = False
max_count = 50
if os.path.exists(CREDENTIALS_FILE):
try:
with open(CREDENTIALS_FILE, 'r', encoding='utf-8') as f:
data = json.load(f)
if isinstance(data, dict):
enabled = data.get("monitor_disarmed_enabled", False)
max_count = data.get("max_disarmed_count", 50)
except Exception:
pass
return enabled, max_count
def save_disarmed_monitor_state(enabled):
"""Guarda el estado del monitoreo de desarmadas exclusivamente en credentials.json de la ruta actual."""
if os.path.exists(CREDENTIALS_FILE):
try:
with open(CREDENTIALS_FILE, 'r', encoding='utf-8') as f:
creds_data = json.load(f)
if isinstance(creds_data, dict):
creds_data["monitor_disarmed_enabled"] = enabled
with open(CREDENTIALS_FILE, 'w', encoding='utf-8') as f:
json.dump(creds_data, f, ensure_ascii=False, indent=2)
log_msg(f"💾 Estado de monitoreo de desarmadas ({enabled}) guardado en: {CREDENTIALS_FILE}")
except Exception as e:
log_msg(f"⚠️ No se pudo actualizar monitor_disarmed_enabled en {CREDENTIALS_FILE}: {e}")
polling_enabled = load_polling_state()
monitor_disarmed_enabled, max_disarmed_count = load_disarmed_monitor_config()
def log_msg(msg):
"""Escribe un mensaje de log con timestamp tanto en la consola (con flush) como en activity.log."""
@@ -579,10 +609,11 @@ def _get_location_sort_priority(loc_item):
def generate_html_report(data):
"""Genera un archivo HTML premium, limpio, claro y compacto con el estatus consolidado."""
now = datetime.datetime.now(ZoneInfo("America/Mexico_City"))
dias = ["lunes", "martes", "miércoles", "jueves", "viernes", "sábado", "domingo"]
meses = ["enero", "febrero", "marzo", "abril", "mayo", "junio", "julio", "agosto", "septiembre", "octubre", "noviembre", "diciembre"]
dias = ["lun", "mar", "mié", "jue", "vie", "sáb", "dom"]
meses = ["ene", "feb", "mar", "abr", "may", "jun", "jul", "ago", "sep", "oct", "nov", "dic"]
timestamp = f"{dias[now.weekday()]}, {now.day} de {meses[now.month - 1]} de {now.year} - {now.strftime('%I:%M:%S %p')}"
timestamp = f"{dias[now.weekday()]}, {now.day} {meses[now.month - 1]} - {now.strftime('%H:%M:%S')}"
disarmed_checked_attr = "checked" if monitor_disarmed_enabled else ""
# Generar checkboxes dinámicos para las cuentas de credentials.json
checkboxes_html = ""
@@ -1233,6 +1264,64 @@ def generate_html_report(data):
.btn-log-refresh:hover {{
background: #2563eb;
}}
/* Tooltip Desarmadas Revisión Constante */
.disarmed-tooltip-wrapper {{
position: relative;
display: inline-flex;
align-items: center;
}}
.disarmed-tooltip-trigger {{
color: #475569;
font-weight: 600;
font-size: 0.75rem;
cursor: help;
text-decoration: underline dotted #94a3b8;
transition: color 0.15s;
}}
.disarmed-tooltip-trigger:hover {{
color: #1e293b;
text-decoration-color: #2563eb;
}}
.disarmed-tooltip-box {{
visibility: hidden;
opacity: 0;
position: absolute;
top: 130%;
left: 50%;
transform: translateX(-50%) translateY(-4px);
width: 320px;
background: #0f172a;
color: #f8fafc;
padding: 12px 14px;
border-radius: 10px;
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.4), 0 8px 10px -6px rgba(0, 0, 0, 0.3);
border: 1px solid #334155;
font-size: 0.75rem;
line-height: 1.45;
z-index: 1000;
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
pointer-events: none;
}}
.disarmed-tooltip-box::after {{
content: '';
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
border-width: 6px;
border-style: solid;
border-color: transparent transparent #0f172a transparent;
}}
.disarmed-tooltip-wrapper:hover .disarmed-tooltip-box {{
visibility: visible;
opacity: 1;
transform: translateX(-50%) translateY(0);
}}
</style>
</head>
<body>
@@ -1249,14 +1338,51 @@ def generate_html_report(data):
</div>
</div>
<div style="display: flex; align-items: center; gap: 12px; flex-wrap: wrap;">
<div class="accounts-selector" style="display: flex; gap: 10px; align-items: center; background: #f8fafc; padding: 6px 12px; border-radius: 8px; border: 1px solid #e2e8f0;">
<span style="font-size: 0.7rem; font-weight: 700; color: #64748b; text-transform: uppercase; letter-spacing: 0.3px; margin-right: 4px;">Cuentas:</span>
<div class="disarmed-tooltip-wrapper accounts-selector" style="display: flex; gap: 10px; align-items: center; background: #f8fafc; padding: 6px 12px; border-radius: 8px; border: 1px solid #e2e8f0;">
<span class="disarmed-tooltip-trigger" style="font-size: 0.7rem; font-weight: 700; color: #64748b; text-transform: uppercase; letter-spacing: 0.3px; margin-right: 4px; text-decoration: underline dotted #94a3b8;">Cuentas:</span>
{checkboxes_html}
<div class="disarmed-tooltip-box">
<div style="font-weight: 700; color: #38bdf8; margin-bottom: 4px; font-size: 0.8rem;">
👤 Selección de Cuentas TotalConnect
</div>
<div>
Permite elegir qué cuentas de usuario (configuradas en <code>credentials.json</code>) serán procesadas al presionar el botón <b>"Actualizar"</b> para consultar sus sucursales.
</div>
</div>
</div>
<div class="disarmed-tooltip-wrapper" style="background: #f8fafc; padding: 6px 12px; border-radius: 8px; border: 1px solid #e2e8f0;">
<label style="display: flex; align-items: center; gap: 6px; cursor: pointer; margin: 0; user-select: none;">
<input type="checkbox" id="disarmed-monitor-cb" {disarmed_checked_attr} onchange="toggleDisarmedMonitor(this)" style="cursor: pointer; width: 14px; height: 14px; accent-color: #2563eb;">
<span class="disarmed-tooltip-trigger">Desarmadas revisión constante</span>
</label>
<div class="disarmed-tooltip-box">
<div style="font-weight: 700; color: #38bdf8; margin-bottom: 4px; font-size: 0.8rem;">
🔄 Monitoreo de Desarmadas en Revisión Constante
</div>
<div>
Al activar esta casilla, las sucursales desarmadas se agregan al <b>Grupo 0</b> de alta prioridad para detectar en tiempo real el instante exacto en que son armadas.
</div>
<div style="margin-top: 8px; padding-top: 6px; border-top: 1px solid #334155; color: #fbbf24; font-size: 0.72rem;">
⚡ <b>Protección por Límite:</b> Si el número total de desarmadas supera el límite configurado (<code>max_disarmed_count:{max_disarmed_count}</code> en <code>credentials.json</code>), esta función se desactivará automáticamente para evitar sobrecargar la API de Honeywell.
</div>
</div>
</div>
<div class="disarmed-tooltip-wrapper" style="position: relative; display: inline-flex;">
<button id="toggle-polling-btn" onclick="togglePolling()" style="display: flex; align-items: center; gap: 6px; padding: 6px 12px; font-weight: 600; font-size: 0.75rem; border-radius: 8px; border: 1px solid #a7f3d0; background: #ecfdf5; color: #065f46; cursor: pointer; transition: all 0.2s;">
<span id="polling-dot" style="width: 8px; height: 8px; border-radius: 50%; background: #10b981; display: inline-block;"></span>
<span id="polling-text">Polling: Activo</span>
</button>
<div class="disarmed-tooltip-box">
<div style="font-weight: 700; color: #38bdf8; margin-bottom: 4px; font-size: 0.8rem;">
🛑/🟢 Control de Polling Automático (Kill-Switch)
</div>
<div>
Activa o pausa las consultas automáticas en segundo plano (cada 30s).<br><br>
- <b>Activo:</b> El motor monitorea eventos SOAP en tiempo real y realiza sondeos por lotes.<br>
- <b>Pausado:</b> Detiene las peticiones automáticas a Honeywell para congelar el rastreo o conservar ancho de banda.
</div>
</div>
</div>
<button id="toggle-polling-btn" onclick="togglePolling()" style="display: flex; align-items: center; gap: 6px; padding: 6px 12px; font-weight: 600; font-size: 0.75rem; border-radius: 8px; border: 1px solid #a7f3d0; background: #ecfdf5; color: #065f46; cursor: pointer; transition: all 0.2s;">
<span id="polling-dot" style="width: 8px; height: 8px; border-radius: 50%; background: #10b981; display: inline-block;"></span>
<span id="polling-text">Polling: Activo</span>
</button>
<button id="refresh-btn">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21.5 2v6h-6M21.34 15.57a10 10 0 1 1-.57-8.38l5.67-5.67"/></svg>
Actualizar
@@ -1403,6 +1529,30 @@ def generate_html_report(data):
}}
}}
async function toggleDisarmedMonitor(checkbox) {{
let toggleUrl = '/toggle-disarmed-monitor';
if (window.location.protocol === 'file:') {{
if (window.location.pathname.indexOf(':') !== -1) {{
toggleUrl = 'http://localhost:8080/toggle-disarmed-monitor';
}} else {{
toggleUrl = 'http://192.168.100.50:8080/toggle-disarmed-monitor';
}}
}}
try {{
const res = await fetch(toggleUrl, {{ method: 'POST' }});
const data = await res.json();
if (data.status === 'success') {{
if (checkbox) checkbox.checked = data.enabled;
}} else if (data.message) {{
alert('Atención: ' + data.message);
if (checkbox) checkbox.checked = false;
}}
}} catch (e) {{
alert('No se pudo cambiar el monitoreo de desarmadas.');
if (checkbox) checkbox.checked = !checkbox.checked;
}}
}}
function updatePollingUI(enabled) {{
const dot = document.getElementById('polling-dot');
const text = document.getElementById('polling-text');
@@ -1441,6 +1591,10 @@ def generate_html_report(data):
if (data.enabled !== undefined) {{
updatePollingUI(data.enabled);
}}
if (data.disarmed_monitor_enabled !== undefined) {{
const cb = document.getElementById('disarmed-monitor-cb');
if (cb) cb.checked = data.disarmed_monitor_enabled;
}}
// Si se completó un nuevo ciclo de actualización en segundo plano, recargar automáticamente las tarjetas
if (data.last_poll) {{
if (currentLastPoll !== null && currentLastPoll !== data.last_poll) {{
@@ -1710,6 +1864,8 @@ class DashboardHandler(http.server.SimpleHTTPRequestHandler):
self.end_headers()
data = {
"enabled": polling_enabled,
"disarmed_monitor_enabled": monitor_disarmed_enabled,
"max_disarmed_count": max_disarmed_count,
"last_poll": last_poll_timestamp,
"loading_status": current_loading_status
}
@@ -1827,6 +1983,25 @@ class DashboardHandler(http.server.SimpleHTTPRequestHandler):
self.send_header('Access-Control-Allow-Headers', 'Content-Type')
self.end_headers()
self.wfile.write(json.dumps({"status": "success", "enabled": polling_enabled}).encode('utf-8'))
elif self.path == '/toggle-disarmed-monitor':
global monitor_disarmed_enabled
monitor_disarmed_enabled = not monitor_disarmed_enabled
save_disarmed_monitor_state(monitor_disarmed_enabled)
state_str = "activado" if monitor_disarmed_enabled else "desactivado"
log_msg(f"🔄 Monitoreo de sucursales desarmadas {state_str} por el usuario.")
if monitor_disarmed_enabled:
polling_trigger_event.set()
self.send_response(200)
self.send_header('Content-type', 'application/json')
self.send_header('Access-Control-Allow-Origin', '*')
self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
self.send_header('Access-Control-Allow-Headers', 'Content-Type')
self.end_headers()
self.wfile.write(json.dumps({
"status": "success",
"enabled": monitor_disarmed_enabled,
"max_count": max_disarmed_count
}).encode('utf-8'))
else:
self.send_response(404)
self.end_headers()
@@ -2254,11 +2429,39 @@ def run_update(accounts=None, exclude=None, full_refresh=False):
active_locs_with_obj.sort(key=get_download_epoch)
# 3. Construir Grupo 0 (Alarmas Activas y Triggers SOAP - SIEMPRE incluidas obligatoriamente)
# Se extraen ANTES de los demás grupos para garantizar presencia y prioridad en cada ciclo.
# 3. Construir Grupo 0 (Alarmas Activas, Triggers SOAP y Desarmadas en Revisión Constante)
global monitor_disarmed_enabled, max_disarmed_count
group_0 = []
group_0_ids = set()
non_alarm_locs = []
# Pre-calcular sucursales desarmadas si la opción está activa
disarmed_candidates = []
if monitor_disarmed_enabled:
for loc_id, location in active_locs_with_obj:
loc_id_str = str(loc_id)
prev = last_loc_map.get(loc_id_str, {})
status_val = prev.get('status')
if isinstance(status_val, int):
try:
status_val = ArmingState(status_val)
except Exception:
pass
is_disarmed = (status_val == ArmingState.DISARMED or status_val == 10200 or str(status_val).upper() == "DISARMED")
if is_disarmed:
disarmed_candidates.append((loc_id, location))
# Auto-desactivar si excede el límite máximo configurado
if len(disarmed_candidates) > max_disarmed_count:
log_msg(f" ⚠️ Monitoreo de desarmadas desactivado automáticamente: hay {len(disarmed_candidates)} sucursales desarmadas (supera el límite configurado de {max_disarmed_count}).")
monitor_disarmed_enabled = False
save_disarmed_monitor_state(False)
disarmed_candidates = []
elif disarmed_candidates:
log_msg(f" 🟢 Monitoreo de desarmadas activo: {len(disarmed_candidates)} sucursales forzadas en Grupo 0 (Límite: {max_disarmed_count}).")
disarmed_ids = {x[0] for x in disarmed_candidates} if monitor_disarmed_enabled else set()
for loc_id, location in active_locs_with_obj:
loc_id_str = str(loc_id)
prev = last_loc_map.get(loc_id_str, {})
@@ -2282,11 +2485,17 @@ def run_update(accounts=None, exclude=None, full_refresh=False):
any(z.get('status') == 'Alarma' for z in prev.get('active_zones', []))
)
has_soap_trigger = prev.get('needs_full_update', False)
is_disarmed_monitored = loc_id in disarmed_ids
if is_alarm or has_soap_trigger:
if is_alarm or has_soap_trigger or is_disarmed_monitored:
group_0.append((loc_id, location))
group_0_ids.add(loc_id)
reason_label = "Alarma" if is_alarm else "Evento SOAP"
if is_alarm:
reason_label = "Alarma"
elif has_soap_trigger:
reason_label = "Evento SOAP"
else:
reason_label = "Desarmada - Revisión Constante"
log_msg(f" 🚨 Grupo 0 ({reason_label}): '{location.location_name}' forzado en este ciclo.")
else:
non_alarm_locs.append((loc_id, location))
BIN
View File
Binary file not shown.
+14 -18
View File
@@ -1,48 +1,44 @@
@echo off
echo =======================================================
echo Actualizando Repositorio Git y creando Tag v06.08.01
echo Actualizando Repositorio Git y creando Tag v06.08.02
echo =======================================================
git add .
git commit -m "feat(polling): prioridad en Grupo 0 para alarmas y eventos SOAP e indicador de escaneo en vivo (v06.08.01)" -m "- Logica de Grupo 0:^
git commit -m "feat(ui/polling): monitoreo de desarmadas en Grupo 0, tooltips explicativos y fecha compacta (v06.08.02)" -m "- Monitoreo de Desarmadas & Grupo 0:^
* Se unificaron las alarmas activas (particiones, zonas y estado ALARMING) y los eventos pendientes de SOAP (needs_full_update = True) dentro del Grupo 0.^
* Agregado switch 'Desarmadas revision constante' para incluir sucursales desarmadas en Grupo 0.^
* Garantiza ejecucion al inicio del ciclo de polling en las primeras posiciones, evitando la degradacion por el limite de 35 consultas rutinarias.^
* Proteccion por Limite: Autodesactivacion automatica si el numero de desarmadas supera 'max_disarmed_count' en credentials.json.^
* Mantiene auto-limpieza dinamica: al recibir confirmacion de restablecimiento via REST, la sucursal sale del Grupo 0 automaticamente.^
* Nuevo endpoint HTTP /toggle-disarmed-monitor para cambio de estado en caliente.^
^
- UI & Monitor en Tiempo Real:^
- UI & Tooltips Flotantes:^
* Agregado badge visual de escaneo en vivo en el encabezado del tablero.^
* Implementados tooltips explicativos con diseño oscuro emergentes (mouseover) en Cuentas, Desarmadas y Polling Activo.^
* Muestra la sucursal actual en formato compacto (Cargando sucursal: 6001).^
* Implementacion en JS usando setTimeout recursivo, manipulacion directa de textContent y suspension en pestanas inactivas (Page Visibility API).^
* Formato compacto de Fecha/Hora: dias/meses abreviados a 3 letras, sin año y hora en formato 24 horas (ej. dom, 2 ago - 02:36:18).^
^
- Correcciones de Infraestructura:^
- Version & Infraestructura:^
* Se eliminaron rutas absolutas en duro (Z:\, E:\) en save_polling_state().^
* Persistencia de 'monitor_disarmed_enabled' y 'max_disarmed_count' en credentials.json.^
* La configuracion del estado del polling ahora escribe exclusivamente en credentials.json de la ruta de ejecucion actual.^
* Actualizada version a 06.08.01."
* Version actualizada a 06.08.02."
if %ERRORLEVEL% NEQ 0 (
echo Error al realizar el commit o no hay cambios por commitear.
)
echo Creando Tag v06.08.01...
git tag -a v06.08.01 -m "Version 06.08.01 - Prioridad Grupo 0 e indicador de escaneo"
echo Creando Tag v06.08.02...
git tag -a v06.08.02 -m "Version 06.08.02 - Desarmadas en revision constante con tooltip y proteccion por limite"
echo Subiendo cambios y tag a GitHub...
git push origin main
git push origin v06.08.01
git push origin v06.08.02
echo =======================================================
echo Proceso completado exitosamente.