791 lines
37 KiB
PHP
791 lines
37 KiB
PHP
<?php
|
|
$title = 'Dashboard Dispositivos';
|
|
ob_start();
|
|
?>
|
|
|
|
<!-- Header con título y acciones -->
|
|
<div class="mb-6">
|
|
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
|
<div>
|
|
<h1 class="text-3xl font-bold text-slate-900">Dispositivos</h1>
|
|
<p class="text-slate-600 mt-1">Gestiona tus dispositivos ESP conectados</p>
|
|
</div>
|
|
<div class="flex items-center gap-3">
|
|
<label class="flex items-center gap-2 cursor-pointer select-none bg-white px-3 py-2 rounded-lg border border-slate-200 shadow-sm">
|
|
<input type="checkbox" id="hideOfflineDevices" class="w-4 h-4 text-emerald-600 rounded focus:ring-emerald-500">
|
|
<span class="text-sm font-medium text-slate-700">Ocultar Inactivos (offline)</span>
|
|
</label>
|
|
<button id="pageCreateDevice" class="inline-flex items-center gap-2 px-4 py-2.5 text-sm font-semibold rounded-lg bg-emerald-600 text-white hover:bg-emerald-700 shadow-sm transition-all" type="button">
|
|
<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="M12 4v16m8-8H4" />
|
|
</svg>
|
|
Nuevo dispositivo
|
|
</button>
|
|
<button id="pageCreateSector" class="inline-flex items-center gap-2 px-4 py-2.5 text-sm font-medium rounded-lg bg-white text-slate-700 hover:bg-slate-50 border border-slate-200 shadow-sm transition-all" type="button">
|
|
<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="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
|
|
</svg>
|
|
Gestionar sectores
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Lista de dispositivos -->
|
|
<div id="lista-dispositivos"></div>
|
|
|
|
<!-- Modal Crear Dispositivo -->
|
|
<div id="modalCrearDispositivo" class="fixed inset-0 z-50 hidden">
|
|
<div data-modal-overlay class="absolute inset-0 bg-black/50 backdrop-blur-sm"></div>
|
|
<div class="relative mx-auto mt-20 max-w-lg rounded-xl bg-white shadow-2xl">
|
|
<div class="flex items-center justify-between px-6 py-4 border-b border-slate-200 bg-gradient-to-r from-emerald-50 to-emerald-100">
|
|
<h5 class="m-0 font-bold text-slate-900 text-lg">Crear nuevo dispositivo</h5>
|
|
<button type="button" class="text-slate-400 hover:text-slate-600 transition-colors" data-modal-close aria-label="Cerrar">
|
|
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<div class="p-6">
|
|
<form id="form-add" class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
<div><input name="chipid" placeholder="ChipID" class="w-full px-4 py-2.5 border-2 border-slate-200 rounded-lg focus:outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-200 transition-all" required></div>
|
|
<div><input name="tag" placeholder="Tag" class="w-full px-4 py-2.5 border-2 border-slate-200 rounded-lg focus:outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-200 transition-all" required></div>
|
|
<div><input name="nombre" placeholder="Modelo" class="w-full px-4 py-2.5 border-2 border-slate-200 rounded-lg focus:outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-200 transition-all"></div>
|
|
<div><input name="ciudad" placeholder="Ciudad" class="w-full px-4 py-2.5 border-2 border-slate-200 rounded-lg focus:outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-200 transition-all"></div>
|
|
<div class="md:col-span-2">
|
|
<select name="sector" class="w-full px-4 py-2.5 border-2 border-slate-200 rounded-lg focus:outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-200 transition-all" required>
|
|
<option value="">Seleccione un sector</option>
|
|
</select>
|
|
</div>
|
|
<div class="md:col-span-1"><button class="w-full inline-flex items-center justify-center px-4 py-2.5 text-sm font-semibold rounded-lg bg-emerald-600 text-white hover:bg-emerald-700 shadow-sm transition-all" type="submit">Agregar</button></div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Modal Gestión de Sectores -->
|
|
<div id="modalGestionSectores" class="fixed inset-0 z-50 hidden">
|
|
<div data-modal-overlay class="absolute inset-0 bg-black/50 backdrop-blur-sm"></div>
|
|
<div class="relative mx-auto mt-20 max-w-lg rounded-xl bg-white shadow-2xl">
|
|
<div class="flex items-center justify-between px-4 py-3 border-b border-gray-200">
|
|
<h5 class="m-0 font-semibold">🏷️ Gestión de Sectores</h5>
|
|
<button type="button" class="text-gray-500 hover:text-gray-700" data-modal-close aria-label="Cerrar">✕</button>
|
|
</div>
|
|
<form id="form-sector">
|
|
<div class="p-4">
|
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-3 mb-3">
|
|
<div class="md:col-span-2">
|
|
<input id="input-nuevo-sector" name="sector" placeholder="Nombre del sector" class="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring focus:ring-blue-200" required>
|
|
</div>
|
|
</div>
|
|
<ul id="lista-sectores" class="list-group divide-y divide-gray-100"></ul>
|
|
</div>
|
|
<div class="px-4 py-3 border-t border-gray-200 flex items-center justify-end gap-2">
|
|
<button type="button" class="inline-flex items-center px-4 py-2 text-sm font-medium rounded border border-gray-300 text-gray-700 hover:bg-gray-100" data-modal-close>Cancelar</button>
|
|
<button class="inline-flex items-center px-4 py-2 text-sm font-medium rounded bg-emerald-600 text-white hover:bg-emerald-700" type="submit">Crear sector</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Modal Confirmar Eliminación de Sector (Tailwind) -->
|
|
<div id="modalConfirmEliminarSector" class="fixed inset-0 z-50 hidden">
|
|
<div data-modal-overlay class="absolute inset-0 bg-black/40"></div>
|
|
<div class="relative mx-auto mt-24 max-w-sm rounded-md bg-white shadow-lg">
|
|
<div class="px-5 py-4 border-b border-gray-200">
|
|
<h5 class="m-0 font-semibold text-gray-900">Confirmar eliminación</h5>
|
|
</div>
|
|
<div class="px-5 py-4 text-sm text-gray-700">
|
|
¿Eliminar el sector <span class="font-semibold" id="sectorAEliminar"></span>? Esta acción no se puede deshacer.
|
|
</div>
|
|
<div class="px-5 py-3 border-t border-gray-200 flex items-center justify-end gap-2">
|
|
<button type="button" 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-100" data-modal-close>Cancelar</button>
|
|
<button type="button" id="confirmDeleteSectorBtn" class="inline-flex items-center px-3 py-1.5 text-sm font-medium rounded bg-red-600 text-white hover:bg-red-700">Eliminar</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Modal Editar Dispositivo (Tailwind) -->
|
|
<div id="modalEditarDispositivo" class="fixed inset-0 z-50 hidden">
|
|
<div data-modal-overlay class="absolute inset-0 bg-black/40"></div>
|
|
<div class="relative mx-auto mt-20 max-w-lg rounded-md bg-white shadow-lg">
|
|
<form id="form-edit">
|
|
<div class="flex items-center justify-between px-4 py-3 border-b border-gray-200">
|
|
<h5 class="m-0 font-semibold">✏️ Editar dispositivo</h5>
|
|
<button type="button" class="text-gray-500 hover:text-gray-700" data-modal-close aria-label="Cerrar">✕</button>
|
|
</div>
|
|
<div class="p-4 grid grid-cols-2 gap-3">
|
|
<input type="hidden" name="chipid">
|
|
<div><input name="tag" placeholder="Tag" class="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring focus:ring-blue-200" required></div>
|
|
<div><input name="nombre" placeholder="Modelo" class="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring focus:ring-blue-200"></div>
|
|
<div><input name="ciudad" placeholder="Ciudad" class="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring focus:ring-blue-200"></div>
|
|
<div>
|
|
<select name="sector" class="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring focus:ring-blue-200" required>
|
|
<option value="">Seleccione un sector</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div class="px-4 py-3 border-t border-gray-200 text-right">
|
|
<button class="inline-flex items-center px-4 py-2 text-sm font-medium rounded bg-blue-600 text-white hover:bg-blue-700" type="submit">Guardar cambios</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
// Estados actuales por chipid (solo en RAM, provistos por API)
|
|
const puertosDispositivos = {}; // chipid -> { pin: { estado, modo, disp } }
|
|
let latestFirmwareVersion = null; // Versión más reciente del firmware
|
|
|
|
// Cargar versión de firmware del manifest
|
|
function loadLatestFirmwareVersion() {
|
|
fetch('/assets/flash/esp8266-manifest.json')
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
latestFirmwareVersion = data.version || null;
|
|
console.log('Versión de firmware más reciente:', latestFirmwareVersion);
|
|
})
|
|
.catch(err => {
|
|
console.error('Error cargando versión de firmware:', err);
|
|
});
|
|
}
|
|
|
|
// Comparar versiones de firmware
|
|
function compareFirmwareVersion(deviceVersion) {
|
|
if (!latestFirmwareVersion || !deviceVersion) return 'unknown';
|
|
return deviceVersion === latestFirmwareVersion ? 'updated' : 'outdated';
|
|
}
|
|
|
|
// Íconos: se proveen globalmente desde layout.php (window.loadIcons / window.applyIcons)
|
|
// Fallback: si los SVG no cargaron aún, mostrar el texto también en XS
|
|
function ensureActionIcons(context) {
|
|
try {
|
|
const root = document.querySelector(context);
|
|
if (!root) return;
|
|
// Columna Acción (sm+)
|
|
root.querySelectorAll('#lista-dispositivos td:nth-last-child(2) a, #lista-dispositivos td:nth-last-child(2) button')
|
|
.forEach(btn => {
|
|
const icon = btn.querySelector('.icon');
|
|
const label = btn.querySelector('span:not(.icon)');
|
|
if (!(icon && icon.querySelector('svg')) && label) label.classList.remove('hidden');
|
|
});
|
|
// Toolbar móvil (XS) dentro de la primera celda
|
|
root.querySelectorAll('#lista-dispositivos td .sm\\:hidden a, #lista-dispositivos td .sm\\:hidden button')
|
|
.forEach(btn => {
|
|
const icon = btn.querySelector('.icon');
|
|
let srLabel = btn.querySelector('span.sr-only');
|
|
if (!(icon && icon.querySelector('svg'))) {
|
|
// Si no hay SVG, mostrar texto corto en lugar de sr-only
|
|
if (srLabel) srLabel.classList.remove('sr-only');
|
|
// Si no hay etiqueta, crear una abreviada
|
|
if (!srLabel) {
|
|
const abbr = document.createElement('span');
|
|
abbr.textContent = btn.getAttribute('aria-label') || 'Acción';
|
|
abbr.className = 'ml-1';
|
|
btn.appendChild(abbr);
|
|
}
|
|
}
|
|
});
|
|
} catch {}
|
|
}
|
|
|
|
// Botones de acciones del header
|
|
document.addEventListener('DOMContentLoaded', function(){
|
|
const btnCreateDevice = document.getElementById('pageCreateDevice');
|
|
if (btnCreateDevice) btnCreateDevice.addEventListener('click', function(){
|
|
if (window.openModal) window.openModal('modalCrearDispositivo');
|
|
});
|
|
const btnCreateSector = document.getElementById('pageCreateSector');
|
|
if (btnCreateSector) btnCreateSector.addEventListener('click', function(){
|
|
if (window.openModal) window.openModal('modalGestionSectores');
|
|
});
|
|
|
|
// Checkbox para ocultar inactivos
|
|
const hideOfflineCheckbox = document.getElementById('hideOfflineDevices');
|
|
if (hideOfflineCheckbox) {
|
|
// Restaurar estado desde localStorage
|
|
try {
|
|
const savedState = localStorage.getItem('devices_hideOffline');
|
|
if (savedState !== null) {
|
|
hideOfflineCheckbox.checked = savedState === 'true';
|
|
}
|
|
} catch(e) {}
|
|
|
|
// Event listener para filtrar dispositivos
|
|
hideOfflineCheckbox.addEventListener('change', function() {
|
|
// Guardar preferencia
|
|
try {
|
|
localStorage.setItem('devices_hideOffline', this.checked);
|
|
} catch(e) {}
|
|
|
|
// Aplicar filtro a las tarjetas existentes
|
|
filterDevices();
|
|
});
|
|
}
|
|
});
|
|
|
|
// Helper: aplica clases Tailwind destacadas al badge de estado (responsivo)
|
|
function applyEstadoClasses(badge, estado) {
|
|
if (!badge) return;
|
|
// Remover clases previas del badge
|
|
badge.classList.remove('bg-emerald-600', 'bg-gray-500', 'bg-amber-600', 'text-white', 'shadow-sm');
|
|
|
|
// Aplicar fondo al card según estado
|
|
const card = badge.closest('.device-card');
|
|
if (card) {
|
|
card.classList.remove('bg-white', 'bg-emerald-50', 'bg-gray-100');
|
|
if (estado === 'online') {
|
|
card.classList.add('bg-emerald-50');
|
|
} else if (estado === 'offline') {
|
|
card.classList.add('bg-gray-100');
|
|
} else {
|
|
card.classList.add('bg-white');
|
|
}
|
|
}
|
|
|
|
// Asegurar estructura interna del badge
|
|
let dot = badge.querySelector('.estado-dot');
|
|
if (!dot) {
|
|
dot = document.createElement('span');
|
|
dot.className = 'estado-dot inline-block w-3 h-3 md:w-2.5 md:h-2.5 rounded-full mr-1 ring-2 ring-white/60 shrink-0';
|
|
badge.insertBefore(dot, badge.firstChild);
|
|
}
|
|
let textSpan = badge.querySelector('.estado-text');
|
|
if (!textSpan) {
|
|
textSpan = document.createElement('span');
|
|
textSpan.className = 'estado-text';
|
|
badge.appendChild(textSpan);
|
|
}
|
|
// Colores del dot
|
|
const dotOnline = 'bg-emerald-200';
|
|
const dotOffline = 'bg-gray-500';
|
|
const dotUnknown = 'bg-amber-200';
|
|
dot.classList.remove('bg-emerald-200', 'bg-gray-500', 'bg-amber-200');
|
|
dot.classList.add(estado === 'online' ? dotOnline : (estado === 'offline' ? dotOffline : dotUnknown));
|
|
// Texto visible solo en sm+
|
|
textSpan.textContent = estado === 'online' ? 'Online' : (estado === 'offline' ? 'Offline' : 'Desconocido');
|
|
}
|
|
|
|
// Estados actuales por chipid para filtrado
|
|
const estadosDispositivos = {};
|
|
// Estados de pines en tiempo real
|
|
const pinesEstados = {};
|
|
|
|
|
|
// Helper: retorna clases y texto para estado al construir filas (evita repetición)
|
|
function estadoUI(estado) {
|
|
const st = String(estado || '').toLowerCase();
|
|
const badgeCls = (st === 'online')
|
|
? 'bg-emerald-600 text-white shadow-sm'
|
|
: (st === 'offline')
|
|
? 'bg-gray-500 text-white shadow-sm'
|
|
: 'bg-amber-600 text-white shadow-sm';
|
|
const dotCls = (st === 'online') ? 'bg-emerald-200' : (st === 'offline') ? 'bg-gray-500' : 'bg-amber-200';
|
|
const estadoTxt = (st === 'online') ? 'Online' : (st === 'offline' ? 'Offline' : 'Desconocido');
|
|
return { badgeCls, dotCls, estadoTxt };
|
|
}
|
|
|
|
|
|
function updateDispositivoEstado(chipid, estado) {
|
|
estadosDispositivos[chipid] = estado; // Guardar estado
|
|
const cards = document.querySelectorAll(`.device-card[data-chipid="${chipid}"]`);
|
|
cards.forEach(card => {
|
|
const badge = card.querySelector('.badge-estado');
|
|
if (badge) applyEstadoClasses(badge, String(estado || '').toLowerCase());
|
|
});
|
|
|
|
// Reaplicar filtro después de actualizar estado
|
|
filterDevices();
|
|
}
|
|
|
|
function updateDispositivoIP(chipid, ip) {
|
|
const cards = document.querySelectorAll(`.device-card[data-chipid="${chipid}"]`);
|
|
cards.forEach(card => {
|
|
const ipEl = card.querySelector('.device-ip');
|
|
if (ipEl) ipEl.textContent = ip || '-';
|
|
});
|
|
}
|
|
|
|
function updateDispositivoPines(chipid, pines) {
|
|
if (!pinesEstados[chipid]) pinesEstados[chipid] = {};
|
|
Object.assign(pinesEstados[chipid], pines);
|
|
renderPortsPreview(chipid); // Re-renderizar pines
|
|
}
|
|
|
|
// Generar badge de firmware
|
|
function getFirmwareBadge(deviceFirmware) {
|
|
const status = compareFirmwareVersion(deviceFirmware);
|
|
if (status === 'unknown') return '';
|
|
|
|
if (status === 'updated') {
|
|
return `<span class="inline-flex items-center gap-1 px-2 py-0.5 text-[10px] font-semibold rounded bg-emerald-100 text-emerald-700 border border-emerald-300" title="Firmware actualizado: ${deviceFirmware}">
|
|
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
|
|
</svg>
|
|
<span>FW OK</span>
|
|
</span>`;
|
|
} else {
|
|
return `<span class="inline-flex items-center gap-1 px-2 py-0.5 text-[10px] font-semibold rounded bg-amber-100 text-amber-700 border border-amber-300" title="Firmware desactualizado: ${deviceFirmware} (Disponible: ${latestFirmwareVersion})">
|
|
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
|
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
|
|
</svg>
|
|
<span>Actualizar</span>
|
|
</span>`;
|
|
}
|
|
}
|
|
|
|
function cargarDispositivos() {
|
|
api.get('/api/get_dispositivos', { _: Date.now() }).then(data => {
|
|
let html = '<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">';
|
|
|
|
for (const chipid in data) {
|
|
const d = data[chipid];
|
|
const chip = d.chipid || chipid;
|
|
const estado = d.online ? 'online' : 'offline';
|
|
estadosDispositivos[chip] = estado;
|
|
if (d.pines && typeof d.pines === 'object') {
|
|
pinesEstados[chip] = Object.assign(pinesEstados[chip] || {}, d.pines);
|
|
}
|
|
const { badgeCls, dotCls, estadoTxt } = estadoUI(estado);
|
|
|
|
const cardBgClass = (estado === 'online') ? 'bg-emerald-50' : (estado === 'offline') ? 'bg-gray-100' : 'bg-white';
|
|
|
|
html += `
|
|
<div class="device-card ${cardBgClass} rounded-xl shadow-sm border-2 border-slate-200 hover:shadow-lg hover:border-emerald-300 transition-all duration-300 p-5 relative overflow-hidden" data-chipid="${d.chipid}">
|
|
<!-- Barra lateral de estado -->
|
|
<div class="absolute left-0 top-0 bottom-0 w-1 ${estado === 'online' ? 'bg-emerald-500' : 'bg-slate-300'}"></div>
|
|
<!-- Header con Tag y Estado -->
|
|
<div class="flex items-start justify-between mb-3">
|
|
<div class="flex-1">
|
|
<div class="flex items-center gap-2">
|
|
<h3 class="text-lg font-semibold text-gray-900">${d.tag || 'Sin tag'}</h3>
|
|
${d.firmware ? getFirmwareBadge(d.firmware) : ''}
|
|
</div>
|
|
<p class="text-xs text-gray-500 mt-1"><code class="bg-gray-100 px-1.5 py-0.5 rounded">${d.chipid}</code></p>
|
|
</div>
|
|
<span class="badge-estado inline-flex items-center px-2.5 py-1 text-xs font-semibold rounded-full transition ${badgeCls}" title="${estadoTxt}">
|
|
<span class="estado-dot inline-block w-2.5 h-2.5 rounded-full mr-1.5 ring-2 ring-white/60 ${dotCls}"></span>
|
|
<span class="estado-text">${estadoTxt}</span>
|
|
</span>
|
|
</div>
|
|
|
|
<!-- Info del dispositivo -->
|
|
<div class="space-y-2 mb-4 text-sm">
|
|
<div class="flex items-center text-gray-600">
|
|
<span class="font-medium w-20">Modelo:</span>
|
|
<span class="text-gray-900">${d.nombre || '-'}</span>
|
|
</div>
|
|
<div class="flex items-center text-gray-600">
|
|
<span class="font-medium w-20">Ciudad:</span>
|
|
<span class="text-gray-900">${d.ciudad || '-'}</span>
|
|
</div>
|
|
<div class="flex items-center text-gray-600">
|
|
<span class="font-medium w-20">Sector:</span>
|
|
<span class="text-gray-900">${d.sector || '-'}</span>
|
|
</div>
|
|
<div class="flex items-center text-gray-600">
|
|
<span class="font-medium w-20">IP:</span>
|
|
<span class="text-gray-900 device-ip">${d.ip || '-'}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Resumen de Puertos -->
|
|
<div class="mb-4 pb-3 border-b border-slate-200">
|
|
<div class="text-xs font-semibold text-slate-600 mb-2 uppercase">Estados</div>
|
|
<div id="ports-preview-${d.chipid}" class="flex flex-wrap gap-1.5">
|
|
<span class="text-xs text-slate-400">Cargando...</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Botones de acción -->
|
|
<div class="grid grid-cols-2 gap-2">
|
|
<a href="/dashboard?chipid=${d.chipid}" class="inline-flex items-center justify-center gap-1.5 px-3 py-2 text-sm font-medium rounded-md border border-blue-600 text-blue-700 hover:bg-blue-50 transition">
|
|
<span class="icon icon-dashboard" aria-hidden="true"></span>
|
|
<span>Dashboard</span>
|
|
</a>
|
|
<button class="editar-dispositivo inline-flex items-center justify-center gap-1.5 px-3 py-2 text-sm font-medium rounded-md border border-blue-600 text-blue-700 hover:bg-blue-50 transition" data-chipid="${d.chipid}">
|
|
<span class="icon icon-edit" aria-hidden="true"></span>
|
|
<span>Editar</span>
|
|
</button>
|
|
<a href="/puertos?chipid=${d.chipid}" class="inline-flex items-center justify-center gap-1.5 px-3 py-2 text-sm font-medium rounded-md border border-amber-600 text-amber-700 hover:bg-amber-50 transition">
|
|
<span class="icon icon-puzzle" aria-hidden="true"></span>
|
|
<span>Puertos</span>
|
|
</a>
|
|
<button class="reiniciar-dispositivo inline-flex items-center justify-center gap-1.5 px-3 py-2 text-sm font-medium rounded-md border border-red-600 text-red-700 hover:bg-red-50 transition" data-chipid="${d.chipid}">
|
|
<span class="icon icon-reboot" aria-hidden="true"></span>
|
|
<span>Reiniciar</span>
|
|
</button>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
html += '</div>';
|
|
const listaEl = document.getElementById('lista-dispositivos');
|
|
if (listaEl) listaEl.innerHTML = html;
|
|
try { if (window.applyIcons) window.applyIcons('#lista-dispositivos'); } catch {}
|
|
ensureActionIcons('#lista-dispositivos');
|
|
|
|
// Aplicar filtro de ocultar offline si está activado
|
|
filterDevices();
|
|
|
|
// Cargar estados de puertos para cada dispositivo
|
|
for (const chipid in data) {
|
|
loadDevicePorts(chipid);
|
|
}
|
|
}).catch(err => {
|
|
console.error('Error cargando dispositivos:', err);
|
|
mostrarToast('Error al cargar dispositivos', { type: 'danger' });
|
|
});
|
|
}
|
|
|
|
// Función para filtrar dispositivos offline
|
|
function filterDevices() {
|
|
const hideOffline = document.getElementById('hideOfflineDevices')?.checked || false;
|
|
const deviceCards = document.querySelectorAll('.device-card');
|
|
|
|
deviceCards.forEach(card => {
|
|
const chipid = card.dataset.chipid;
|
|
const estado = estadosDispositivos[chipid] || 'offline'; // Usar estado actual o offline por defecto
|
|
|
|
if (hideOffline && estado === 'offline') {
|
|
card.style.display = 'none';
|
|
} else {
|
|
card.style.display = '';
|
|
}
|
|
});
|
|
}
|
|
|
|
// Cargar estados de puertos de un dispositivo
|
|
function loadDevicePorts(chipid) {
|
|
api.get('/api/get_puertos', { chipid: chipid })
|
|
.then(data => {
|
|
if (data && !data.error) {
|
|
puertosDispositivos[chipid] = data;
|
|
renderPortsPreview(chipid);
|
|
}
|
|
})
|
|
.catch(err => {
|
|
console.error(`Error cargando puertos de ${chipid}:`, err);
|
|
});
|
|
}
|
|
|
|
// Renderizar preview de puertos en la tarjeta
|
|
function renderPortsPreview(chipid) {
|
|
const container = document.getElementById(`ports-preview-${chipid}`);
|
|
if (!container) return;
|
|
|
|
const ports = puertosDispositivos[chipid];
|
|
if (!ports) {
|
|
container.innerHTML = '<span class="text-xs text-slate-400">Sin datos</span>';
|
|
return;
|
|
}
|
|
|
|
let html = '';
|
|
let count = 0;
|
|
const maxShow = 6; // Máximo de puertos a mostrar
|
|
|
|
for (let pin in ports) {
|
|
if (pin === 'update') continue;
|
|
if (count >= maxShow) break;
|
|
|
|
const p = ports[pin];
|
|
if (p.modo === 'OUTPUT' && p.disp) {
|
|
const state = (pinesEstados[chipid] && pinesEstados[chipid][pin]) || 'OFF';
|
|
const isOn = (String(state).toUpperCase() === 'ON');
|
|
|
|
html += `
|
|
<div class="inline-flex items-center gap-1 px-2 py-1 rounded text-xs font-medium ${isOn ? 'bg-emerald-100 text-emerald-700 border border-emerald-300' : 'bg-red-50 text-red-700 border border-red-200'}" title="${p.disp} - ${pin} (${isOn ? 'ON' : 'OFF'})">
|
|
<div class="w-1.5 h-1.5 rounded-full ${isOn ? 'bg-emerald-500' : 'bg-red-500'}"></div>
|
|
<span class="truncate max-w-[70px]">${p.disp}</span>
|
|
<span class="font-mono text-[10px] opacity-75">${pin}</span>
|
|
</div>`;
|
|
count++;
|
|
}
|
|
}
|
|
|
|
if (html === '') {
|
|
html = '<span class="text-xs text-slate-400">Sin salidas</span>';
|
|
}
|
|
|
|
container.innerHTML = html;
|
|
}
|
|
|
|
document.addEventListener('click', function(e) {
|
|
const editBtn = e.target.closest('.editar-dispositivo');
|
|
if (!editBtn) return;
|
|
|
|
const chipid = editBtn.dataset.chipid;
|
|
api.get('/api/get_dispositivos').then(data => {
|
|
const d = data[chipid];
|
|
if (!d) return alert("Dispositivo no encontrado");
|
|
|
|
const form = document.getElementById('form-edit');
|
|
if (form) {
|
|
const fields = {
|
|
chipid: d.chipid || '',
|
|
tag: d.tag || '',
|
|
nombre: d.nombre || '',
|
|
ciudad: d.ciudad || '',
|
|
sector: d.sector || ''
|
|
};
|
|
Object.keys(fields).forEach(key => {
|
|
const input = form.querySelector(`[name="${key}"]`);
|
|
if (input) input.value = fields[key];
|
|
});
|
|
window.openModal('modalEditarDispositivo');
|
|
}
|
|
}).catch(err => {
|
|
console.error('Error cargando dispositivo:', err);
|
|
mostrarToast('Error al cargar datos del dispositivo', { type: 'danger' });
|
|
});
|
|
});
|
|
|
|
const formEdit = document.getElementById('form-edit');
|
|
if (formEdit) {
|
|
formEdit.addEventListener('submit', function (e) {
|
|
e.preventDefault();
|
|
const formData = new FormData(this);
|
|
const editado = {};
|
|
formData.forEach((value, key) => editado[key] = value);
|
|
console.log('Enviando datos al servidor:', editado);
|
|
|
|
// Mostrar indicador de carga
|
|
const submitBtn = this.querySelector('button[type="submit"]');
|
|
const originalBtnText = submitBtn.innerHTML;
|
|
submitBtn.disabled = true;
|
|
submitBtn.innerHTML = '<span class="inline-block animate-spin w-4 h-4 border-2 border-white border-t-transparent rounded-full"></span> Procesando...';
|
|
|
|
api.postJSON('/api/edit_dispositivo', editado)
|
|
.then(resp => {
|
|
console.log('Respuesta del servidor:', resp);
|
|
if (resp && resp.success) {
|
|
window.closeModal('modalEditarDispositivo');
|
|
cargarDispositivos();
|
|
mostrarToast('Dispositivo actualizado correctamente', { type: 'success' });
|
|
} else {
|
|
const errorMsg = resp && resp.msg ? resp.msg : 'Error desconocido al editar el dispositivo';
|
|
console.error('Error en la respuesta:', errorMsg);
|
|
mostrarToast(errorMsg, { type: 'danger' });
|
|
}
|
|
})
|
|
.catch(err => {
|
|
console.error('Error en la petición:', err);
|
|
const errorMsg = err.data?.msg || err.data?.message || err.message || 'Error al comunicarse con el servidor';
|
|
mostrarToast(errorMsg, { type: 'danger' });
|
|
})
|
|
.finally(() => {
|
|
// Restaurar el botón
|
|
submitBtn.disabled = false;
|
|
submitBtn.innerHTML = originalBtnText;
|
|
});
|
|
});
|
|
}
|
|
|
|
// ==================== Sectores ====================
|
|
|
|
// Cargar sectores al iniciar
|
|
function cargarSectores() {
|
|
api.get('/api/get_sectores').then(data => {
|
|
let html = '';
|
|
data.forEach(sector => {
|
|
html += `
|
|
<li class="flex items-center justify-between px-3 py-2">
|
|
<span class="sector-nombre text-sm text-gray-800">${sector}</span>
|
|
<div class="flex items-center gap-2">
|
|
<button class="editar-sector inline-flex items-center px-2.5 py-1 text-xs font-medium rounded border border-amber-600 text-amber-700 hover:bg-amber-50" data-sector="${sector}">✏️ <span class="hidden sm:inline">Editar</span></button>
|
|
<button class="eliminar-sector inline-flex items-center px-2.5 py-1 text-xs font-medium rounded border border-red-600 text-red-700 hover:bg-red-50" data-sector="${sector}">🗑️ <span class="hidden sm:inline">Eliminar</span></button>
|
|
</div>
|
|
</li>`;
|
|
});
|
|
const listaEl = document.getElementById('lista-sectores');
|
|
if (listaEl) listaEl.innerHTML = html;
|
|
}).catch(err => {
|
|
console.error('Error cargando sectores:', err);
|
|
mostrarToast('Error al cargar sectores', { type: 'danger' });
|
|
});
|
|
}
|
|
|
|
// Manejo de la edición de sectores
|
|
document.addEventListener('click', function(e) {
|
|
const editBtn = e.target.closest('.editar-sector');
|
|
if (!editBtn) return;
|
|
|
|
const sector = editBtn.dataset.sector;
|
|
const li = editBtn.closest('li');
|
|
const span = li.querySelector('.sector-nombre');
|
|
const nombreOriginal = span.textContent;
|
|
|
|
// Cambiar el nombre a un campo de texto editable
|
|
span.innerHTML = `<input type="text" class="w-full px-2 py-1 border border-gray-300 rounded text-sm focus:outline-none focus:ring focus:ring-blue-200" value="${nombreOriginal}" />`;
|
|
|
|
// Cambiar el texto del botón de editar a guardar
|
|
editBtn.innerHTML = '💾 <span class="hidden sm:inline">Guardar</span>';
|
|
editBtn.classList.remove('border-amber-600', 'text-amber-700', 'hover:bg-amber-50');
|
|
editBtn.classList.add('border-emerald-600', 'text-emerald-700', 'hover:bg-emerald-50');
|
|
|
|
// Cambiar el manejador para guardar
|
|
const saveHandler = function(evt) {
|
|
evt.stopPropagation();
|
|
const input = li.querySelector('input');
|
|
const nuevoNombre = input ? input.value : '';
|
|
|
|
if (nuevoNombre !== nombreOriginal) {
|
|
if (nuevoNombre.trim() === "") {
|
|
mostrarToast("El nombre del sector no puede estar vacío.", { type: 'danger' });
|
|
return;
|
|
}
|
|
|
|
api.postJSON('/api/update_sector', {
|
|
old_sector: nombreOriginal,
|
|
new_sector: nuevoNombre
|
|
}).then(resp => {
|
|
console.log(resp);
|
|
if (resp.success) {
|
|
cargarSectores();
|
|
mostrarToast('Sector actualizado correctamente', { type: 'success' });
|
|
} else {
|
|
mostrarToast(resp.msg || 'Error al actualizar sector', { type: 'danger' });
|
|
}
|
|
}).catch(err => {
|
|
console.error("Error en la actualización del sector:", err);
|
|
mostrarToast("Hubo un error al intentar actualizar el sector.", { type: 'danger' });
|
|
});
|
|
} else {
|
|
cargarSectores();
|
|
}
|
|
editBtn.removeEventListener('click', saveHandler);
|
|
};
|
|
|
|
editBtn.addEventListener('click', saveHandler);
|
|
});
|
|
|
|
// Manejo de la eliminación de sectores con modal de confirmación
|
|
let sectorPendienteEliminar = null;
|
|
|
|
document.addEventListener('click', function(e) {
|
|
const deleteBtn = e.target.closest('.eliminar-sector');
|
|
if (!deleteBtn) return;
|
|
|
|
sectorPendienteEliminar = deleteBtn.dataset.sector;
|
|
const sectorNameEl = document.getElementById('sectorAEliminar');
|
|
if (sectorNameEl) sectorNameEl.textContent = sectorPendienteEliminar;
|
|
if (window.openModal) window.openModal('modalConfirmEliminarSector');
|
|
});
|
|
|
|
const confirmDelBtn = document.getElementById('confirmDeleteSectorBtn');
|
|
if (confirmDelBtn) {
|
|
confirmDelBtn.addEventListener('click', function(){
|
|
if (!sectorPendienteEliminar) return;
|
|
|
|
api.postJSON('/api/delete_sector', { sector: sectorPendienteEliminar })
|
|
.then(resp => {
|
|
if (resp.success) {
|
|
mostrarToast('Sector eliminado', { type: 'success' });
|
|
cargarSectores();
|
|
} else {
|
|
mostrarToast(resp.msg || 'No se pudo eliminar', { type: 'danger' });
|
|
}
|
|
})
|
|
.catch(err => {
|
|
console.error('Error eliminando sector:', err);
|
|
mostrarToast('Error al eliminar sector', { type: 'danger' });
|
|
})
|
|
.finally(() => {
|
|
if (window.closeModal) window.closeModal('modalConfirmEliminarSector');
|
|
sectorPendienteEliminar = null;
|
|
});
|
|
});
|
|
}
|
|
|
|
// Agregar un sector
|
|
const formSector = document.getElementById('form-sector');
|
|
if (formSector) {
|
|
formSector.addEventListener('submit', function (e) {
|
|
e.preventDefault();
|
|
const formData = new FormData(this);
|
|
const datos = {};
|
|
formData.forEach((value, key) => datos[key] = value);
|
|
|
|
api.postJSON('/api/add_sector', datos)
|
|
.then(resp => {
|
|
if (resp.success) {
|
|
this.reset();
|
|
cargarSectores();
|
|
mostrarToast('Sector agregado correctamente', { type: 'success' });
|
|
} else {
|
|
mostrarToast(resp.msg || 'Error al agregar sector', { type: 'danger' });
|
|
}
|
|
})
|
|
.catch(err => {
|
|
console.error('Error:', err);
|
|
mostrarToast('Error al agregar sector', { type: 'danger' });
|
|
});
|
|
});
|
|
}
|
|
|
|
// Cargar sectores en los combos de los modales (Crear y Editar Dispositivo)
|
|
function cargarSectoresEnSelectModal() {
|
|
api.get('/api/get_sectores').then(data => {
|
|
let html = '<option value="">Seleccione un sector</option>';
|
|
data.forEach(sector => {
|
|
html += `<option value="${sector}">${sector}</option>`;
|
|
});
|
|
const selects = document.querySelectorAll('#modalEditarDispositivo select[name="sector"], #modalCrearDispositivo select[name="sector"]');
|
|
selects.forEach(select => select.innerHTML = html);
|
|
}).catch(err => {
|
|
console.error('Error cargando sectores:', err);
|
|
});
|
|
}
|
|
|
|
// Al abrir el modal de creación (legacy bootstrap hook removido). Usar hook global.
|
|
|
|
document.addEventListener('click', function(e) {
|
|
const rebootBtn = e.target.closest('.reiniciar-dispositivo');
|
|
if (!rebootBtn) return;
|
|
|
|
const chipid = rebootBtn.dataset.chipid;
|
|
if (!chipid) return mostrarToast('No se encontró el ChipID.');
|
|
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(resp => {
|
|
mostrarToast(`Comando de reinicio enviado al dispositivo ${chipid}.`);
|
|
rebootBtn.disabled = true;
|
|
})
|
|
.catch(err => {
|
|
mostrarToast('Error al enviar el comando de reinicio.', { type: 'danger' });
|
|
console.error(err);
|
|
});
|
|
});
|
|
|
|
// Inicialización
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
loadLatestFirmwareVersion();
|
|
cargarDispositivos();
|
|
cargarSectores();
|
|
cargarSectoresEnSelectModal();
|
|
if (window.mqttRealtime) {
|
|
window.mqttRealtime.onMessage(function(evt) {
|
|
if (evt.type === 'state') updateDispositivoEstado(evt.chipid, evt.estado);
|
|
if (evt.type === 'ip') updateDispositivoIP(evt.chipid, evt.ip);
|
|
if (evt.type === 'pin') updateDispositivoPines(evt.chipid, { [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';
|
|
}
|