mirror of
https://github.com/cheveguerra/TotalConnect.git
synced 2026-08-19 00:46:37 +00:00
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:
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user