225 lines
8.6 KiB
HTML
225 lines
8.6 KiB
HTML
<!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">Self‑Test 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>
|
||
<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 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);
|
||
}
|
||
|
||
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');
|
||
} 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(); });
|
||
}
|
||
|
||
loadHistory().then(connectSSE);
|
||
</script>
|
||
</body>
|
||
</html>
|