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
+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))