esp/paginas/dashboard.php

720 lines
30 KiB
PHP

<?php
$title = 'Dashboard - ESP Admin';
ob_start();
?>
<!-- Header -->
<div class="mb-6">
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
<div>
<h1 class="text-3xl font-bold text-slate-900 flex items-center gap-2">
<svg class="w-8 h-8 text-emerald-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
Dashboard
</h1>
<p id="device-title" class="text-slate-600 mt-1">Cargando información del dispositivo...</p>
</div>
<a href="/" class="inline-flex items-center gap-2 px-4 py-2.5 text-sm font-semibold rounded-lg bg-white text-slate-700 hover:bg-slate-50 border-2 border-slate-200 shadow-sm transition-all">
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Volver
</a>
</div>
</div>
<!-- Contenedor principal -->
<div class="space-y-6">
<!-- Cards de Estado -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<!-- Estado de Conexión -->
<div class="bg-white rounded-xl border-2 border-slate-200 p-6 shadow-sm hover:shadow-md transition-all">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-slate-900">Estado</h3>
<svg class="w-6 h-6 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<div class="flex items-center gap-3">
<span id="connection-status" class="inline-flex items-center gap-2 px-3 py-1.5 text-sm font-semibold rounded-full bg-slate-100 text-slate-700">
<span class="w-2 h-2 rounded-full bg-slate-400"></span>
Verificando...
</span>
</div>
<div class="mt-4 text-sm text-slate-600">
<span class="font-medium">IP:</span> <span id="device-ip" class="font-mono">Cargando...</span>
</div>
</div>
<!-- Última Actividad -->
<div class="bg-white rounded-xl border-2 border-slate-200 p-6 shadow-sm hover:shadow-md transition-all">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-slate-900">Actividad</h3>
<svg class="w-6 h-6 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div class="text-2xl font-bold text-slate-900" id="last-activity">-</div>
<div class="mt-2 text-sm text-slate-600">Última actualización</div>
</div>
<!-- Acciones Rápidas -->
<div class="bg-white rounded-xl border-2 border-slate-200 p-6 shadow-sm hover:shadow-md transition-all">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-slate-900">Acciones</h3>
<svg class="w-6 h-6 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<div class="space-y-2">
<button id="btn-refresh" class="w-full inline-flex items-center justify-center gap-2 px-3 py-2 text-sm font-medium rounded-lg bg-emerald-600 text-white hover:bg-emerald-700 transition-all">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Actualizar
</button>
<button id="btn-reboot" class="w-full inline-flex items-center justify-center gap-2 px-3 py-2 text-sm font-medium rounded-lg bg-white text-red-600 hover:bg-red-50 border-2 border-red-200 transition-all">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Reiniciar
</button>
</div>
</div>
</div>
<!-- Estados de Pines -->
<div class="bg-white rounded-xl border-2 border-slate-200 shadow-sm overflow-hidden">
<div class="px-6 py-4 border-b border-slate-200 bg-gradient-to-r from-emerald-50 to-emerald-100">
<h2 class="text-lg font-bold text-slate-900 flex items-center gap-2">
<svg class="w-5 h-5 text-emerald-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
Estados de Pines
</h2>
<p class="text-xs text-slate-600 mt-1">Actualización en tiempo real</p>
</div>
<div class="p-6">
<div id="pins-status" class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 xl:grid-cols-6 gap-3">
<div class="text-center text-slate-500 col-span-full py-4">Cargando estados de pines...</div>
</div>
</div>
</div>
<!-- Controles Rapidos -->
<div class="bg-white rounded-xl border-2 border-slate-200 shadow-sm overflow-hidden">
<div class="px-6 py-4 border-b border-slate-200 bg-gradient-to-r from-blue-50 to-blue-100">
<h2 class="text-lg font-bold text-slate-900 flex items-center gap-2">
<svg class="w-5 h-5 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4" />
</svg>
Controles Rapidos
</h2>
<p class="text-xs text-slate-600 mt-1">Control manual de salidas</p>
</div>
<div class="p-6">
<div id="quick-controls" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
<div class="text-center text-slate-500 col-span-full py-4">Cargando controles...</div>
</div>
</div>
</div>
<!-- Botones de Acción Adicionales -->
<div class="flex flex-wrap gap-3">
<button id="btn-config-ports" class="inline-flex items-center gap-2 px-4 py-2.5 text-sm font-medium rounded-lg bg-white text-blue-600 hover:bg-blue-50 border-2 border-blue-200 transition-all">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Configurar Puertos
</button>
<button id="btn-mqtt" class="inline-flex items-center gap-2 px-4 py-2.5 text-sm font-medium rounded-lg bg-white text-purple-600 hover:bg-purple-50 border-2 border-purple-200 transition-all">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
Ver Info MQTT
</button>
</div>
</div>
<!-- Modal MQTT -->
<div id="mqttModal" class="fixed inset-0 z-50 hidden" aria-hidden="true">
<div id="mqttModalOverlay" class="absolute inset-0 bg-black/40"></div>
<div class="relative mx-auto mt-24 w-11/12 max-w-2xl">
<div class="bg-white rounded-lg shadow-lg overflow-hidden">
<div class="flex items-center justify-between px-4 py-2 border-b border-gray-200">
<h3 class="text-lg font-semibold">Información MQTT</h3>
<button id="mqttModalClose" class="inline-flex items-center justify-center w-8 h-8 rounded hover:bg-gray-100" aria-label="Cerrar">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="w-5 h-5"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
<div class="p-4">
<pre id="mqttJson" class="text-sm bg-gray-50 p-3 rounded border border-gray-200 overflow-auto max-h-96">{}</pre>
</div>
<div class="flex justify-end gap-2 px-4 py-3 border-t border-gray-200">
<button id="mqttModalClose2" class="inline-flex items-center px-3 py-1.5 text-sm font-medium rounded border border-gray-300 text-gray-700 hover:bg-gray-50">Cerrar</button>
</div>
</div>
</div>
</div>
<script>
const urlParams = new URLSearchParams(window.location.search);
let chipid = urlParams.get('chipid');
if (chipid) chipid = String(chipid).trim().toUpperCase();
if (!chipid) {
document.getElementById('device-info').innerHTML = '<div class="alert alert-danger">Error: No se especificó un ChipID en la URL.</div>';
throw new Error('Falta el parámetro chipid');
}
let deviceData = {};
let pinsData = {};
let lastUpdateTime = null;
let lastKnownIP = null;
// Estados actuales por chipid
const estadosDispositivos = {};
// Estado conocido de cada pin ("ON"|"OFF"|otro)
const pinStates = {};
// Pines en tiempo real
const pinesEstados = {};
const pendingPinCommands = {};
// Comparación de chipid insensible a mayúsculas/minúsculas
function sameChip(a, b) {
if (!a || !b) return false;
return String(a).trim().toLowerCase() === String(b).trim().toLowerCase();
}
function isPinOn(valor) {
const v = String(normalizePinValue(valor) || '').toLowerCase().trim();
return v === 'on' || v === '1' || v === 'true' || v === 'high';
}
function normalizePinValue(valor) {
if (valor && typeof valor === 'object') {
if (Object.prototype.hasOwnProperty.call(valor, 'valor')) return valor.valor;
if (Object.prototype.hasOwnProperty.call(valor, 'value')) return valor.value;
}
return valor;
}
function setSwitchVisual(switchEl, isOn, options) {
if (!switchEl) return;
const opts = options || {};
const card = switchEl.closest('.quick-control-card') || switchEl.closest('.bg-white');
const root = card || switchEl.closest('label') || switchEl.parentElement;
const label = root ? root.querySelector('.state-label') : null;
const track = root ? root.querySelector('.switch-track') : null;
const thumb = root ? root.querySelector('.switch-thumb') : null;
const icon = card ? card.querySelector('svg') : null;
const stateColor = opts.pending ? 'text-amber-700' : (isOn ? 'text-emerald-700' : 'text-red-700');
const trackColor = opts.pending ? 'bg-amber-500' : (isOn ? 'bg-emerald-500' : 'bg-red-500');
switchEl.checked = !!isOn;
if (label) {
label.textContent = isOn ? 'ON' : 'OFF';
label.className = `state-label text-sm font-medium ${stateColor}`;
}
if (track) {
track.className = `switch-track relative w-14 h-8 rounded-full transition-colors duration-300 ${trackColor}`;
if (opts.pulse) {
track.classList.add('animate-pulse');
setTimeout(() => track.classList.remove('animate-pulse'), 300);
}
}
if (thumb) {
thumb.className = `switch-thumb absolute left-1 top-1 w-6 h-6 bg-white rounded-full shadow-md transition-transform duration-300 ${isOn ? 'translate-x-6' : 'translate-x-0'}`;
}
if (icon) {
icon.className = `w-5 h-5 ${isOn ? 'text-emerald-500' : 'text-slate-400'}`;
}
}
function clearPendingPin(pin) {
const pending = pendingPinCommands[pin];
if (!pending) return null;
clearTimeout(pending.timer);
delete pendingPinCommands[pin];
return pending;
}
function confirmPendingPin(pin, valor) {
const pending = pendingPinCommands[pin];
if (!pending) return;
const received = isPinOn(normalizePinValue(valor)) ? 'ON' : 'OFF';
if (received !== pending.expected) return;
clearPendingPin(pin);
const sw = document.querySelector(`.switch-pin[data-pin="${pin}"]`) || pending.switchEl;
if (sw) {
setSwitchVisual(sw, pending.expected === 'ON');
sw.disabled = false;
}
mostrarToast(`${pin} confirmado ${pending.expected}`, { type: 'success', delay: 1800 });
}
function failPendingPin(pin, message) {
const pending = clearPendingPin(pin);
if (!pending) return;
const sw = document.querySelector(`.switch-pin[data-pin="${pin}"]`) || pending.switchEl;
if (sw) {
setSwitchVisual(sw, pending.previous === 'ON', { pulse: true });
sw.disabled = false;
}
if (!pinesEstados[chipid]) pinesEstados[chipid] = {};
pinesEstados[chipid][pin] = pending.previous;
let toastMessage = message || `No se pudo enviar comando a ${pin}`;
if (String(toastMessage).indexOf('WebSocket JSON') !== -1) {
toastMessage = `Comando enviado, pero ${pin} no confirmo ${pending.expected} en valores/${pin}. El dispositivo sigue reportando otro estado.`;
}
mostrarToast(toastMessage, { type: 'danger', delay: 5000 });
}
function updateConnectionStatus(estado) {
const statusElement = document.getElementById('connection-status');
if (estado === 'online') {
statusElement.innerHTML = '<span class="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></span> Online';
statusElement.className = 'inline-flex items-center gap-2 px-3 py-1.5 text-sm font-semibold rounded-full bg-emerald-100 text-emerald-700';
} else if (estado === 'offline') {
statusElement.innerHTML = '<span class="w-2 h-2 rounded-full bg-red-500"></span> Offline';
statusElement.className = 'inline-flex items-center gap-2 px-3 py-1.5 text-sm font-semibold rounded-full bg-red-100 text-red-700';
} else {
statusElement.innerHTML = '<span class="w-2 h-2 rounded-full bg-slate-400"></span> Desconocido';
statusElement.className = 'inline-flex items-center gap-2 px-3 py-1.5 text-sm font-semibold rounded-full bg-slate-100 text-slate-700';
}
}
function updateLastActivity() {
const element = document.getElementById('last-activity');
if (lastUpdateTime) {
const now = new Date();
const diff = Math.floor((now - lastUpdateTime) / 1000);
if (diff < 10) {
element.textContent = 'Ahora mismo';
} else if (diff < 60) {
element.textContent = `Hace ${diff} segundos`;
} else if (diff < 3600) {
const minutes = Math.floor(diff / 60);
element.textContent = `Hace ${minutes} minuto${minutes !== 1 ? 's' : ''}`;
} else if (diff < 86400) {
const hours = Math.floor(diff / 3600);
element.textContent = `Hace ${hours} hora${hours !== 1 ? 's' : ''}`;
} else {
const days = Math.floor(diff / 86400);
element.textContent = `Hace ${days} día${days !== 1 ? 's' : ''}`;
}
} else {
element.textContent = 'Sin datos';
}
}
function loadDeviceInfo() {
api.get('/api/get_dispositivos').then(data => {
const deviceKey = Object.keys(data || {}).find(key => sameChip(key, chipid));
if (deviceKey) {
deviceData = data[deviceKey];
document.getElementById('device-title').innerHTML =
`<strong>${deviceData.tag}</strong> (${deviceData.chipid}) - ${deviceData.ciudad}, ${deviceData.sector}`;
const ipEl = document.getElementById('device-ip');
if (ipEl) {
ipEl.textContent = lastKnownIP || deviceData.ip || 'No disponible';
}
// Configurar botón a puertos
(function(){
const btn = document.getElementById('btn-config-ports');
if (btn) {
btn.onclick = function(){ window.location.href = `/puertos?chipid=${chipid}`; };
btn.disabled = false;
}
})();
// Estado inicial: usar API como fallback
updateConnectionStatus(deviceData.online ? 'online' : 'offline');
if (deviceData.pines && typeof deviceData.pines === 'object') {
updateDispositivoPines(chipid, deviceData.pines);
Object.keys(deviceData.pines).forEach(pin => updatePinStatus(pin, deviceData.pines[pin]));
}
} else {
document.getElementById('device-info').innerHTML =
'<div class="alert alert-warning">Dispositivo no encontrado en el registro.</div>';
}
}).catch(err => {
console.error('Error cargando dispositivo:', err);
document.getElementById('device-info').innerHTML =
'<div class="alert alert-danger">Error al cargar información del dispositivo.</div>';
});
}
function loadPinsStatus() {
api.get('/api/get_puertos', { chipid: chipid }).then(data => {
if (data && !data.error) {
pinsData = data;
renderPinsStatus();
renderQuickControls();
// Forzar sincronización inicial: solicitar estado actual si no hay datos
setTimeout(() => {
Object.keys(pinsData).forEach(pin => {
if (pin === 'update') return;
const p = pinsData[pin];
if (p.modo === 'OUTPUT') {
// El estado inicial llega por MQTT WebSocket.
}
});
}, 500);
// Re-render leve luego para aplicar estados iniciales
setTimeout(renderQuickControls, 800);
} else {
document.getElementById('pins-status').innerHTML =
'<div class="col-12"><div class="alert alert-info">No hay configuración de puertos para este dispositivo.</div></div>';
}
}).catch(err => {
console.error('Error cargando pines:', err);
document.getElementById('pins-status').innerHTML =
'<div class="col-12"><div class="alert alert-danger">Error al cargar estados de pines.</div></div>';
});
}
function renderPinsStatus() {
let html = '';
for (let pin in pinsData) {
if (pin === 'update') continue;
const p = pinsData[pin];
const modo = p.modo || '';
// Estilo inicial según modo
let badgeCls = 'bg-slate-100 text-slate-700 border-slate-200';
let badgeText = 'Sin datos';
let iconColor = 'text-slate-400';
if (modo === 'OUTPUT') {
badgeCls = 'bg-amber-100 text-amber-700 border-amber-200';
badgeText = 'Salida';
iconColor = 'text-amber-500';
} else if (modo === 'INPUT' || modo === 'INPUT_PULLUP') {
badgeCls = 'bg-blue-100 text-blue-700 border-blue-200';
badgeText = 'Entrada';
iconColor = 'text-blue-500';
}
html += `
<div class="bg-white rounded-lg border-2 border-slate-200 p-4 hover:shadow-md hover:-translate-y-0.5 transition-all duration-300">
<div class="flex items-center justify-between mb-2">
<span class="font-mono text-sm font-bold text-slate-900">${pin}</span>
<svg class="w-4 h-4 ${iconColor}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<span class="pin-status inline-flex items-center justify-center w-full px-2 py-1 text-xs font-semibold rounded-md border-2 ${badgeCls}" data-pin="${pin}">${badgeText}</span>
<div class="mt-2 text-xs text-slate-600 text-center truncate" title="${p.disp || 'Sin asignar'}">${p.disp || 'Sin asignar'}</div>
</div>`;
}
document.getElementById('pins-status').innerHTML = html;
}
function updatePinStatus(pin, valor) {
const badge = document.querySelector(`.pin-status[data-pin="${pin}"]`);
const prev = String(pinStates[pin] || '');
const normalizedInput = normalizePinValue(valor);
const v = String(normalizedInput || '').toLowerCase().trim();
const isOn = isPinOn(normalizedInput);
const normalizedValue = isOn ? 'ON' : 'OFF';
console.log(`updatePinStatus: ${pin}="${valor}" -> normalized="${normalizedValue}" (${isOn})`); // Debug
if (badge) {
const base = 'pin-status inline-flex items-center justify-center w-full px-2 py-1 text-xs font-semibold rounded-md border-2';
let variant = 'bg-slate-100 text-slate-700 border-slate-200';
if (isOn) variant = 'bg-emerald-100 text-emerald-700 border-emerald-300';
else if (!isOn && v !== '') variant = 'bg-red-100 text-red-700 border-red-300';
badge.className = base + ' ' + variant;
badge.textContent = isOn ? 'ON' : 'OFF';
}
// Guardar estado normalizado y reflejar en el switch si existe
pinStates[pin] = normalizedValue;
const sw = document.querySelector(`.switch-pin[data-pin="${pin}"]`);
if (sw) {
console.log(`Setting switch ${pin} checked=${isOn}`); // Debug
setSwitchVisual(sw, isOn, { pulse: prev !== normalizedValue });
}
}
function renderQuickControls() {
const container = document.getElementById('quick-controls');
if (!container) return;
// Verificar si ya hay controles, entonces solo actualizar estados
const existingSwitches = container.querySelectorAll('.switch-pin');
if (existingSwitches.length > 0) {
// Solo actualizar estados de switches existentes
existingSwitches.forEach(switchEl => {
const pin = switchEl.dataset.pin;
const pinValue = (pinesEstados[chipid] && pinesEstados[chipid][pin]) || 'OFF'; // Estado dinámico
if (pendingPinCommands[pin]) {
setSwitchVisual(switchEl, pendingPinCommands[pin].expected === 'ON', { pending: true });
} else {
setSwitchVisual(switchEl, isPinOn(pinValue));
}
});
return; // No reconstruir el HTML
}
// Primera vez: construir HTML completo
let html = '';
for (let pin in pinsData) {
if (pin === 'update') continue;
const p = pinsData[pin];
if (p.modo === 'OUTPUT' && p.disp) {
const pinValue = (pinesEstados[chipid] && pinesEstados[chipid][pin]) || 'OFF'; // Usar estado dinámico
const isOn = isPinOn(pinValue);
const notas = p.notas ? `<div class="text-xs text-slate-600 italic mb-2">${p.notas}</div>` : '';
html += `
<div class="quick-control-card bg-white rounded-lg border-2 border-slate-200 p-4 hover:shadow-md transition-all">
<div class="flex items-center justify-between mb-3">
<div class="flex-1">
<div class="font-semibold text-slate-900">${p.disp}</div>
<div class="text-xs text-slate-500 font-mono">${pin}</div>
</div>
<svg class="w-5 h-5 ${isOn ? 'text-emerald-500' : 'text-slate-400'}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
${notas}
<label class="flex items-center justify-between cursor-pointer select-none">
<span class="state-label text-sm font-medium ${isOn ? 'text-emerald-700' : 'text-red-700'}">${isOn ? 'ON' : 'OFF'}</span>
<div class="relative inline-flex items-center">
<input class="switch-pin absolute opacity-0 w-0 h-0" type="checkbox" data-pin="${pin}" ${isOn ? 'checked' : ''}>
<div class="switch-track relative w-14 h-8 rounded-full transition-colors duration-300 ${isOn ? 'bg-emerald-500' : 'bg-red-500'}">
<div class="switch-thumb absolute left-1 top-1 w-6 h-6 bg-white rounded-full shadow-md transition-transform duration-300 ${isOn ? 'translate-x-6' : 'translate-x-0'}"></div>
</div>
</div>
</label>
</div>`;
}
}
if (html === '') {
html = '<div class="col-span-full text-center text-slate-500 py-8">No hay salidas configuradas para controlar.</div>';
}
container.innerHTML = html;
}
function refreshDashboard() {
loadDeviceInfo();
loadPinsStatus();
mostrarToast('Dashboard actualizado');
}
// Construir objeto de información MQTT (desde estado y config de pines)
function buildMqttInfo() {
try {
const estado = deviceData && deviceData.online ? 'online' : 'offline';
const info = {
chipid: chipid,
estado: estado,
ip: lastKnownIP || '',
firmware: (deviceData && deviceData.firmware) ? String(deviceData.firmware) : null,
pines: {}
};
// Combinar config de pines con estado ON/OFF si se conoce
Object.keys(pinsData || {}).forEach(function(pin){
if (pin === 'update') return;
const p = pinsData[pin] || {};
info.pines[pin] = {
disp: p.disp || null,
modo: p.modo || null,
estado: (pinesEstados[chipid] && pinesEstados[chipid][pin]) || null
};
});
return info;
} catch(e) {
return { error: true, message: 'No se pudo construir la información', detail: String(e) };
}
}
// Event listeners
document.getElementById('btn-reboot').addEventListener('click', function() {
if (!confirm(`¿Confirmas reiniciar el dispositivo ${chipid}?`)) return;
const topic = `dispositivo/${chipid}/comando/reboot`;
const payload = '1';
const publisher = window.mqttRealtime
? window.mqttRealtime.publish(topic, payload)
: api.postJSON('/api/mqtt_publish', { topic, payload });
publisher
.then(function(resp) {
mostrarToast(`Comando de reinicio enviado al dispositivo ${chipid}.`);
document.getElementById('btn-reboot').disabled = true;
})
.catch(function(err) {
mostrarToast('Error al enviar el comando de reinicio.');
console.error(err);
});
});
document.getElementById('btn-refresh').addEventListener('click', refreshDashboard);
// Modal MQTT: abrir/cerrar y pintar JSON
document.getElementById('btn-mqtt').addEventListener('click', function(){
try {
const modal = document.getElementById('mqttModal');
const pre = document.getElementById('mqttJson');
const data = buildMqttInfo();
pre.textContent = JSON.stringify(data, null, 2);
modal.classList.remove('hidden');
} catch(e) { console.error(e); }
});
function closeMqttModal(){
const modal = document.getElementById('mqttModal');
if (modal) modal.classList.add('hidden');
}
document.getElementById('mqttModalClose').addEventListener('click', closeMqttModal);
document.getElementById('mqttModalClose2').addEventListener('click', closeMqttModal);
document.getElementById('mqttModalOverlay').addEventListener('click', closeMqttModal);
// Control de pines con switch (ON/OFF vía MQTT) - Delegación de eventos
document.addEventListener('change', function(e) {
const switchPin = e.target.closest('.switch-pin');
if (!switchPin) return;
const pin = switchPin.dataset.pin;
const checked = switchPin.checked;
const action = checked ? 'ON' : 'OFF';
const prevAction = checked ? 'OFF' : 'ON';
switchPin.disabled = true;
// Feedback visual mínimo mientras llega la confirmación por MQTT.
const label = switchPin.closest('label')?.querySelector('.state-label');
const track = switchPin.closest('label')?.querySelector('.switch-track');
if (label) label.textContent = action;
if (track) {
track.className = 'switch-track relative w-14 h-8 rounded-full transition-colors duration-300 bg-amber-500';
track.classList.add('animate-pulse');
}
setSwitchVisual(switchPin, checked, { pending: true, pulse: true });
clearPendingPin(pin);
pendingPinCommands[pin] = {
expected: action,
previous: prevAction,
switchEl: switchPin,
timer: setTimeout(function() {
failPendingPin(pin, `No se confirmó el envío a ${pin}. Revisá la conexión WebSocket JSON o el estado del dispositivo.`);
}, 6000)
};
console.log(`Switch clicked: ${pin} -> ${action}`); // Debug
const topic = `dispositivo/${chipid}/comando`;
const payload = { alias: String(pin), valor: String(action) };
const publisher = window.mqttRealtime
? window.mqttRealtime.publish(topic, payload, { qos: 0, retain: true })
: api.postJSON('/api/mqtt_publish', { topic, payload, qos: 0, retain: true });
publisher
.then(function() {
console.log(`MQTT published: ${topic} -> ${JSON.stringify(payload)}`); // Debug log
mostrarToast(`Enviando ${action} a ${pin}...`, { type: 'warning', delay: 1800 });
})
.catch(function(err) {
const msg = (err.data && (err.data.message || err.data.msg)) || err.message || 'Error al enviar comando';
failPendingPin(pin, msg);
console.error('mqtt_publish error', err);
});
});
function updateDispositivoPines(chipid, pines) {
if (!pinesEstados[chipid]) pinesEstados[chipid] = {};
Object.keys(pines || {}).forEach(function(pin) {
pinesEstados[chipid][pin] = normalizePinValue(pines[pin]);
});
renderQuickControls(); // Re-renderizar controles
Object.keys(pines || {}).forEach(pin => confirmPendingPin(pin, pines[pin]));
}
function isValueStateTopic(topic) {
return /\/valores\/[^/]+$/.test('/' + String(topic || '').replace(/^\/+/, ''));
}
function refreshDashboard() {
loadDeviceInfo();
loadPinsStatus();
mostrarToast('Dashboard actualizado');
}
// Inicialización
document.addEventListener('DOMContentLoaded', function() {
loadDeviceInfo();
loadPinsStatus();
// Inicializar contador de actividad
updateLastActivity();
// Re-renderizar controles tras un breve tiempo para reflejar estados iniciales
setTimeout(renderQuickControls, 1200);
if (window.mqttRealtime) {
window.mqttRealtime.onMessage(function(evt) {
if (!sameChip(evt.chipid, chipid)) {
console.debug('[Dashboard MQTT] evento ignorado', evt);
return;
}
console.debug('[Dashboard MQTT] evento recibido', evt);
if (evt.type === 'state') {
if (deviceData) deviceData.online = evt.estado === 'online';
updateConnectionStatus(evt.estado);
}
if (evt.type === 'ip') {
lastKnownIP = evt.ip || lastKnownIP;
const ipEl = document.getElementById('device-ip');
if (ipEl && evt.ip) ipEl.textContent = evt.ip;
}
if (evt.type === 'pin') {
if (!isValueStateTopic(evt.topic)) {
console.debug('[Dashboard MQTT] comando publicado, esperando valores/<pin>', evt);
return;
}
updateDispositivoPines(chipid, { [evt.alias]: evt.valor });
updatePinStatus(evt.alias, evt.valor);
}
});
}
});
</script>
<?php
$content = ob_get_clean();
// Si no se está renderizando como parcial, incluir el layout principal
if (!defined('RENDER_PARTIAL')) {
include __DIR__ . '/../layouts/layout.php';
}
?>