159 lines
5.9 KiB
HTML
159 lines
5.9 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>
|
||
</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>
|
||
</div>
|
||
<div id="list" class="list"></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');
|
||
let total = 0;
|
||
const btnClear = document.getElementById('clear');
|
||
const btnCopy = document.getElementById('copy');
|
||
const btnExportCsv = document.getElementById('exportCsv');
|
||
let history = [];
|
||
|
||
function renderItem(ev) {
|
||
const div = document.createElement('div');
|
||
const isAuth = ev.type === 'authorize';
|
||
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'] || '-';
|
||
div.innerHTML = `
|
||
<div><strong>${isAuth ? 'Authorize' : 'Accounting'}</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> • VLAN: <code>${ev.vlan}</code> • 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();
|
||
});
|
||
|
||
// 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('clear', () => { loadHistory(); });
|
||
}
|
||
|
||
loadHistory().then(connectSSE);
|
||
</script>
|
||
</body>
|
||
</html>
|