Files
radiusNucleo/node-api/public/index.html

288 lines
11 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>RADIUS Dashboard</title>
<style>
:root { color-scheme: light dark; }
body { font-family: system-ui, sans-serif; margin: 0; }
header { padding: 12px 16px; background: #0b5; color: #fff; }
main { padding: 16px; }
.meta { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
.chip { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 12px; background: #eef; color: #334; }
.list { margin-top: 12px; }
.item { border: 1px solid #ccc; border-radius: 8px; padding: 8px 12px; margin-bottom: 8px; }
.type-auth { border-left: 4px solid #0b5; }
.type-acct { border-left: 4px solid #09f; }
.attrs { font-family: ui-monospace, monospace; font-size: 12px; white-space: pre-wrap; background: rgba(0,0,0,0.04); padding: 8px; border-radius: 6px; }
.toolbar { display: flex; gap: 8px; align-items: center; margin: 8px 0; }
button { padding: 6px 10px; }
</style>
</head>
<body>
<header>
<h1>RADIUS Dashboard</h1>
</header>
<main>
<div class="meta">
<span class="chip" id="status">Conectando…</span>
<span class="chip" id="count">0 eventos</span>
<span class="chip" id="band">VLAN 2 • 10/10 Mbps</span>
<span class="chip" id="radiusState" style="background:#fee; color:#900; display:none;">RADIUS: Recargando…</span>
</div>
<div class="toolbar">
<button id="refresh">Recargar historial</button>
<label><input type="checkbox" id="autoScroll" checked /> Autoscroll</label>
<button id="selfTest">SelfTest RADIUS</button>
<span class="chip" id="selfTestStatus">Idle</span>
<button id="clear">Limpiar</button>
<button id="copy">Copiar</button>
<button id="exportCsv">Exportar CSV</button>
<button id="addUser">Añadir usuario</button>
</div>
<h3>Usuarios</h3>
<div id="users"></div>
<hr />
<h3>Eventos</h3>
<div id="list" class="list"></div>
<div id="modal" style="display:none; position:fixed; inset:0; background:rgba(0,0,0,0.4); align-items:center; justify-content:center;">
<div style="background:#fff; color:#222; padding:16px; border-radius:8px; min-width:300px; max-width:90%;">
<h3 style="margin-top:0;">Nuevo usuario</h3>
<div style="display:flex; flex-direction:column; gap:8px;">
<label>Usuario <input id="u_username" /></label>
<label>Contraseña <input id="u_password" type="password" /></label>
<label>VLAN <input id="u_vlan" type="number" min="1" value="2" /></label>
</div>
<div style="display:flex; gap:8px; margin-top:12px;">
<button id="saveUser">Guardar</button>
<button id="cancelUser">Cancelar</button>
</div>
</div>
</div>
</main>
<script>
const list = document.getElementById('list');
const usersDiv = document.getElementById('users');
const statusEl = document.getElementById('status');
const countEl = document.getElementById('count');
const btnRefresh = document.getElementById('refresh');
const btnSelfTest = document.getElementById('selfTest');
const selfTestStatus = document.getElementById('selfTestStatus');
const autoScroll = document.getElementById('autoScroll');
const radiusState = document.getElementById('radiusState');
let total = 0;
const btnClear = document.getElementById('clear');
const btnCopy = document.getElementById('copy');
const btnExportCsv = document.getElementById('exportCsv');
const btnAddUser = document.getElementById('addUser');
const modal = document.getElementById('modal');
const u_username = document.getElementById('u_username');
const u_password = document.getElementById('u_password');
const u_vlan = document.getElementById('u_vlan');
const saveUser = document.getElementById('saveUser');
const cancelUser = document.getElementById('cancelUser');
let history = [];
function renderItem(ev) {
const div = document.createElement('div');
const isAuth = ev.type === 'authorize' || ev.type === 'authorize-inner';
div.className = 'item ' + (isAuth ? 'type-auth' : 'type-acct');
const user = ev.attrs?.['User-Name'] || ev.attrs?.['User-Name*0'] || '-';
const nas = ev.attrs?.['NAS-IP-Address'] || '-';
const calling = ev.attrs?.['Calling-Station-Id'] || '-';
const called = ev.attrs?.['Called-Station-Id'] || '-';
const kind = ev.type === 'authorize-inner' ? 'EAP Inner' : (isAuth ? 'Authorize' : 'Accounting');
div.innerHTML = `
<div><strong>${kind}</strong> • <small>${new Date(ev.ts).toLocaleString()}</small></div>
<div>Usuario: <code>${user}</code> • NAS: <code>${nas}</code> • STA: <code>${calling}</code> • AP: <code>${called}</code></div>
${isAuth ? `<div>Decisión: <strong>${ev.decision||'-'}</strong>${ev.vlan?` • VLAN: <code>${ev.vlan}</code>`:''}${ev.bandwidth?` • BW: <code>${(ev.bandwidth?.down/1e6)||10}↓ / ${(ev.bandwidth?.up/1e6)||10}↑ Mbps</code>`:''}</div>` : ''}
<div class="attrs">${JSON.escape ? JSON.escape(JSON.stringify(ev.attrs, null, 2)) : JSON.stringify(ev.attrs, null, 2)}</div>
`;
list.appendChild(div);
total += 1;
countEl.textContent = `${total} eventos`;
if (autoScroll.checked) div.scrollIntoView({ behavior: 'smooth', block: 'end' });
}
async function loadHistory() {
const r = await fetch('/api/requests');
const data = await r.json();
list.innerHTML = '';
history = data.items || [];
total = 0;
history.forEach(renderItem);
}
async function loadUsers() {
const r = await fetch('/api/users');
const data = await r.json();
const users = data.items || [];
usersDiv.innerHTML = '';
const table = document.createElement('table');
table.style.width = '100%';
table.cellPadding = 6;
table.innerHTML = `<tr><th>Usuario</th><th>VLAN</th><th>Estado</th><th>Acciones</th></tr>`;
users.forEach(u => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${u.username}</td>
<td>${u.vlan}</td>
<td>${u.disabled ? 'Desactivado' : 'Activo'}</td>
<td>
<button data-edit="${u.username}">Editar</button>
<button data-toggle="${u.username}">${u.disabled ? 'Activar' : 'Desactivar'}</button>
<button data-del="${u.username}">Eliminar</button>
</td>`;
table.appendChild(tr);
});
usersDiv.appendChild(table);
usersDiv.querySelectorAll('button[data-edit]').forEach(btn => btn.addEventListener('click', () => editUser(btn.getAttribute('data-edit'))));
usersDiv.querySelectorAll('button[data-toggle]').forEach(btn => btn.addEventListener('click', () => toggleUser(btn.getAttribute('data-toggle'))));
usersDiv.querySelectorAll('button[data-del]').forEach(btn => btn.addEventListener('click', () => deleteUser(btn.getAttribute('data-del'))));
}
async function editUser(username) {
const r = await fetch('/api/users');
const data = await r.json();
const u = (data.items || []).find(x => x.username === username);
if (!u) return alert('Usuario no encontrado');
modal.style.display = 'flex';
u_username.value = u.username;
u_password.value = u.password || '';
u_vlan.value = u.vlan || '2';
}
async function toggleUser(username) {
const r = await fetch('/api/users');
const data = await r.json();
const u = (data.items || []).find(x => x.username === username);
if (!u) return alert('Usuario no encontrado');
await fetch(`/api/users/${encodeURIComponent(username)}`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ disabled: !u.disabled })
});
await loadUsers();
}
async function deleteUser(username) {
if (!confirm(`¿Eliminar usuario ${username}?`)) return;
await fetch(`/api/users/${encodeURIComponent(username)}`, { method: 'DELETE' });
await loadUsers();
}
btnRefresh.addEventListener('click', loadHistory);
btnSelfTest.addEventListener('click', async () => {
selfTestStatus.textContent = 'Running…';
try {
const r = await fetch('/test/radius', { method: 'POST' });
const data = await r.json();
if (data.ok) selfTestStatus.textContent = `OK (${data.result.code}, ${data.result.rtt_ms}ms)`;
else selfTestStatus.textContent = `Error (${data.error||r.status})`;
} catch (e) {
selfTestStatus.textContent = `Error (${e.message})`;
}
});
btnClear.addEventListener('click', async () => {
if (!confirm('¿Limpiar historial de eventos?')) return;
await fetch('/api/requests', { method: 'DELETE' });
await loadHistory();
});
btnCopy.addEventListener('click', async () => {
const text = JSON.stringify(history, null, 2);
try {
async function copyText(t) {
if (navigator.clipboard && navigator.clipboard.writeText) {
return navigator.clipboard.writeText(t);
}
const ta = document.createElement('textarea');
ta.value = t;
ta.style.position = 'fixed';
ta.style.opacity = '0';
ta.setAttribute('readonly', '');
document.body.appendChild(ta);
ta.focus();
ta.select();
ta.setSelectionRange(0, ta.value.length);
const ok = document.execCommand('copy');
document.body.removeChild(ta);
if (!ok) throw new Error('copy command failed');
}
await copyText(text);
alert('Copiado al portapapeles');
} catch (e) {
alert('No se pudo copiar: ' + (e && e.message ? e.message : e));
}
});
btnExportCsv.addEventListener('click', () => {
const a = document.createElement('a');
a.href = '/api/requests.csv';
a.download = '';
document.body.appendChild(a);
a.click();
a.remove();
});
btnAddUser.addEventListener('click', () => {
modal.style.display = 'flex';
u_username.value = '';
u_password.value = '';
u_vlan.value = '2';
u_username.focus();
});
cancelUser.addEventListener('click', () => {
modal.style.display = 'none';
});
saveUser.addEventListener('click', async () => {
const username = u_username.value.trim();
const password = u_password.value;
const vlan = u_vlan.value || '2';
if (!username || !password) {
alert('Usuario y contraseña son obligatorios');
return;
}
try {
const r = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password, vlan })
});
const data = await r.json();
if (!data.ok) throw new Error(data.error || 'Error desconocido');
modal.style.display = 'none';
alert('Usuario guardado');
await loadUsers();
} catch (e) {
alert('Error al guardar: ' + e.message);
}
});
// SSE live events
function connectSSE() {
const es = new EventSource('/events');
es.addEventListener('open', () => statusEl.textContent = 'Conectado');
es.addEventListener('error', () => statusEl.textContent = 'Reintentando…');
es.addEventListener('message', (e) => {
try { const ev = JSON.parse(e.data); renderItem(ev); } catch {}
});
es.addEventListener('status', (e) => {
try {
const s = JSON.parse(e.data);
radiusState.style.display = s.radius_reloading ? 'inline-block' : 'none';
} catch {}
});
es.addEventListener('clear', () => { loadHistory(); });
}
Promise.all([loadUsers(), loadHistory()]).then(connectSSE);
</script>
</body>
</html>