251 lines
11 KiB
HTML
251 lines
11 KiB
HTML
<!doctype html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
<title>Kaya Dashboard</title>
|
|
<style>
|
|
:root {
|
|
--bg-color: #f4f4f4;
|
|
--card-bg: #ffffff;
|
|
--text-main: #1a1a1a;
|
|
--text-secondary: #666666;
|
|
--accent-orange: #ff6600;
|
|
--network-green: #00c853;
|
|
--border-color: #e0e0e0;
|
|
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
--font-mono: 'SF Mono', 'Menlo', 'Monaco', 'Courier New', monospace;
|
|
}
|
|
|
|
* { box-sizing: border-box; }
|
|
body { font-family: var(--font-sans); background: var(--bg-color); color: var(--text-main); margin: 0; padding: 28px; line-height: 1.4; }
|
|
header { display: flex; justify-content: space-between; align-items: center; gap: 8px; padding-bottom: 16px; margin-bottom: 22px; border-bottom: 6px solid var(--accent-orange); }
|
|
header h2 { margin: 0; font-size: 30px; font-weight: 800; letter-spacing: -1px; text-transform: uppercase; }
|
|
#stamp { font-family: var(--font-mono); font-size: 12px; color: var(--text-secondary); background: #fff; padding: 6px 12px; border: 1px solid var(--border-color); border-radius: 2px; }
|
|
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(380px, 1fr)); gap: 18px; }
|
|
.card { background: var(--card-bg); border: 1px solid var(--border-color); border-radius: 4px; padding: 18px; box-shadow: 0 4px 6px rgba(0,0,0,0.02); min-width: 0; }
|
|
.card h3 { margin: 0 0 12px 0; font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: 1.2px; color: var(--accent-orange); border-bottom: 1px solid var(--border-color); padding-bottom: 8px; }
|
|
.table-wrap { overflow-x: auto; width: 100%; }
|
|
table { width: 100%; border-collapse: collapse; font-size: 12px; table-layout: fixed; }
|
|
th { font-weight: 600; color: var(--text-secondary); padding: 10px 6px; border-bottom: 2px solid var(--border-color); font-size: 11px; text-transform: uppercase; white-space: nowrap; }
|
|
td { padding: 8px 6px; border-bottom: 1px solid #eee; font-family: var(--font-mono); color: var(--text-main); vertical-align: middle; overflow: hidden; text-overflow: ellipsis; }
|
|
tr:last-child td { border-bottom: none; }
|
|
button { background: #fff; color: #d32f2f; border: 1px solid #d32f2f; border-radius: 3px; padding: 6px 10px; cursor: pointer; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.4px; white-space: nowrap; }
|
|
button:hover { background: #d32f2f; color: #fff; }
|
|
pre { white-space: pre-wrap; margin: 0; font-family: var(--font-mono); font-size: 11px; background: #fafafa; padding: 12px; border: 1px solid var(--border-color); color: #555; max-height: 340px; overflow: auto; }
|
|
.net-green { color: var(--network-green); font-weight: bold; }
|
|
.full-width { grid-column: 1 / -1; }
|
|
.ip-cell { word-break: break-word; white-space: normal; }
|
|
.uri-cell { max-width: 260px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
.port-icon-wrapper { display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; }
|
|
svg.port-icon { width: 24px; height: 24px; }
|
|
.port-body { fill: none; stroke: #ddd; stroke-width: 2; }
|
|
.port-led { fill: #ddd; transition: all 0.2s; }
|
|
.port-icon.connected .port-body { stroke: var(--accent-orange); }
|
|
.port-icon.connected .port-led { fill: var(--accent-orange); }
|
|
.port-icon.active .port-body { stroke: var(--network-green); }
|
|
.port-icon.active .port-led { fill: var(--network-green); animation: blink-traffic 1s steps(2) infinite; }
|
|
@keyframes blink-traffic { 0% { opacity: 1; } 50% { opacity: 0; } 100% { opacity: 1; } }
|
|
.graph-container { width: 100%; height: 220px; background: #fafafa; border: 1px solid var(--border-color); border-radius: 4px; overflow: hidden; }
|
|
canvas#trafficGraph { width: 100%; height: 100%; display: block; }
|
|
.graph-stats { display: flex; justify-content: space-between; gap: 8px; margin-bottom: 10px; font-family: var(--font-mono); font-size: 14px; flex-wrap: wrap; }
|
|
.stat-item { display: flex; align-items: center; gap: 8px; }
|
|
.stat-val { font-weight: bold; font-size: 16px; }
|
|
.legend-dot { width: 10px; height: 10px; border-radius: 50%; }
|
|
@media (max-width: 900px) {
|
|
body { padding: 12px; }
|
|
.grid { grid-template-columns: 1fr; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<h2>Kaya</h2>
|
|
<div id="stamp"></div>
|
|
</header>
|
|
|
|
<div class="grid">
|
|
<div class="card">
|
|
<h3>Self Info</h3>
|
|
<pre id="self"></pre>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h3>Flows (Sessions)</h3>
|
|
<div class="table-wrap"><table id="flows"></table></div>
|
|
</div>
|
|
|
|
<div class="card full-width">
|
|
<h3>Peers Switch</h3>
|
|
<div class="table-wrap"><table id="peers"></table></div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h3>Paths</h3>
|
|
<div class="table-wrap"><table id="paths"></table></div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h3>Tree</h3>
|
|
<div class="table-wrap"><table id="tree"></table></div>
|
|
</div>
|
|
|
|
<div class="card full-width">
|
|
<h3>Total Bandwidth (Real-time)</h3>
|
|
<div class="graph-stats">
|
|
<div class="stat-item">
|
|
<div class="legend-dot" style="background: var(--network-green);"></div>
|
|
<div>⬇ Download: <span id="downSpeedEl" class="stat-val net-green">0.00 Mbit/s</span></div>
|
|
</div>
|
|
<div class="stat-item">
|
|
<div class="legend-dot" style="background: var(--accent-orange);"></div>
|
|
<div>⬆ Upload: <span id="upSpeedEl" class="stat-val" style="color: var(--accent-orange)">0.00 Mbit/s</span></div>
|
|
</div>
|
|
</div>
|
|
<div class="graph-container"><canvas id="trafficGraph"></canvas></div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
const peerStore = new Map();
|
|
const graphData = { maxPoints: 60, download: [], upload: [] };
|
|
|
|
async function call(path, payload){
|
|
const r = await fetch(path,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
|
|
if(!r.ok) alert(await r.text());
|
|
await reload();
|
|
}
|
|
function tHead(cols){return `<tr>${cols.map(c=>`<th>${c}</th>`).join('')}</tr>`}
|
|
function esc(v){return String(v??'').replace(/[&<>]/g,m=>({'&':'&','<':'<','>':'>'}[m]))}
|
|
function peerAction(id, action){
|
|
const p = peerStore.get(id); if(!p) return;
|
|
if(action==='disconnect') return call('/api/peer/traffic',{uri:p.uri,enabled:false});
|
|
}
|
|
function bytesPerSecToMbit(bytesPerSec) {
|
|
const mbits = (Number(bytesPerSec) * 8) / 1000000;
|
|
return Number.isFinite(mbits) ? mbits : 0;
|
|
}
|
|
function fmtMbit(bytesPerSec) {
|
|
return bytesPerSecToMbit(bytesPerSec).toFixed(2) + ' Mbit/s';
|
|
}
|
|
|
|
function drawGraph() {
|
|
const canvas = document.getElementById('trafficGraph');
|
|
const ctx = canvas.getContext('2d');
|
|
const rect = canvas.parentNode.getBoundingClientRect();
|
|
canvas.width = rect.width;
|
|
canvas.height = rect.height;
|
|
const w = canvas.width, h = canvas.height;
|
|
|
|
ctx.clearRect(0, 0, w, h);
|
|
ctx.strokeStyle = '#e0e0e0';
|
|
ctx.lineWidth = 1;
|
|
ctx.beginPath();
|
|
for(let i=0; i<5; i++) {
|
|
const y = (h/4) * i;
|
|
ctx.moveTo(0, y);
|
|
ctx.lineTo(w, y);
|
|
}
|
|
ctx.stroke();
|
|
|
|
if (graphData.download.length < 2) return;
|
|
|
|
const maxVal = Math.max(...graphData.download, ...graphData.upload) * 1.1 || 1;
|
|
const drawLine = (data, color) => {
|
|
ctx.strokeStyle = color.trim();
|
|
ctx.lineWidth = 2;
|
|
ctx.lineJoin = 'round';
|
|
ctx.beginPath();
|
|
const step = w / (graphData.maxPoints - 1);
|
|
data.forEach((val, index) => {
|
|
const x = index * step;
|
|
const y = h - ((val / maxVal) * h);
|
|
if (index === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
|
|
});
|
|
ctx.stroke();
|
|
ctx.lineTo(w, h);
|
|
ctx.lineTo(0, h);
|
|
ctx.fillStyle = color.trim() + '22';
|
|
ctx.fill();
|
|
};
|
|
|
|
drawLine(graphData.upload, getComputedStyle(document.documentElement).getPropertyValue('--accent-orange'));
|
|
drawLine(graphData.download, getComputedStyle(document.documentElement).getPropertyValue('--network-green'));
|
|
}
|
|
|
|
async function reload(){
|
|
const res = await fetch('/api/status');
|
|
if (!res.ok) {
|
|
throw new Error('Failed to load dashboard status: HTTP ' + res.status);
|
|
}
|
|
const s = await res.json();
|
|
|
|
document.getElementById('stamp').textContent = 'Updated ' + new Date(s.now).toLocaleString();
|
|
document.getElementById('self').textContent = JSON.stringify(s.self,null,2);
|
|
|
|
let totalRx = 0, totalTx = 0;
|
|
const peers = [tHead(['Port','URI','Remote','Up','IP','Uptime','RTT','RX','TX','⬇ RX Rate','⬆ TX Rate','Action'])];
|
|
peerStore.clear();
|
|
|
|
for(const p of s.peers){
|
|
const id = btoa(p.uri).replace(/=/g,'');
|
|
peerStore.set(id, p);
|
|
totalRx += Number(p.rx_rate || 0);
|
|
totalTx += Number(p.tx_rate || 0);
|
|
|
|
const isActive = (p.rx_rate > 0 || p.tx_rate > 0);
|
|
const isConnected = !!p.up;
|
|
const portStateClass = isActive ? 'active' : (isConnected ? 'connected' : '');
|
|
const portIconHtml = `<div class="port-icon-wrapper"><svg class="port-icon ${portStateClass}" viewBox="0 0 24 24"><rect class="port-body" x="4" y="6" width="16" height="12" rx="2" /><path class="port-body" d="M8 10h8v4H8z" /><circle class="port-led" cx="17" cy="9" r="1.5" /></svg></div>`;
|
|
|
|
peers.push(`<tr>
|
|
<td>${portIconHtml}</td>
|
|
<td class="uri-cell" title="${esc(p.uri)}">${esc(p.uri)}</td>
|
|
<td>${esc(p.remote)}</td>
|
|
<td style="font-weight:bold; color:${p.up?'var(--accent-orange)':'#aaa'}">${p.up?'UP':'DOWN'}</td>
|
|
<td class="ip-cell">${esc(p.ip)}</td>
|
|
<td>${esc(p.uptime)}</td>
|
|
<td>${esc(p.rtt)}</td>
|
|
<td>${esc(p.rx)}</td>
|
|
<td>${esc(p.tx)}</td>
|
|
<td class="net-green">⬇ ${fmtMbit(p.rx_rate)}</td>
|
|
<td style="color:var(--accent-orange)">⬆ ${fmtMbit(p.tx_rate)}</td>
|
|
<td><button onclick="peerAction('${id}','disconnect')">Disconnect</button></td>
|
|
</tr>`);
|
|
}
|
|
document.getElementById('peers').innerHTML = peers.join('');
|
|
|
|
const flows=[tHead(['IP','RX','TX','Uptime'])];
|
|
for(const f of s.sessions) {
|
|
flows.push(`<tr><td class="ip-cell">${esc(f.ip)}</td><td>${esc(f.rx)}</td><td>${esc(f.tx)}</td><td>${esc(f.uptime)}</td></tr>`);
|
|
}
|
|
document.getElementById('flows').innerHTML=flows.join('');
|
|
|
|
const paths=[tHead(['IP','Path'])];
|
|
for(const p of s.paths) paths.push(`<tr><td class="ip-cell">${esc(p.ip)}</td><td class="ip-cell">${esc(JSON.stringify(p.path))}</td></tr>`);
|
|
document.getElementById('paths').innerHTML=paths.join('');
|
|
|
|
const tree=[tHead(['IP','Parent'])];
|
|
for(const t of s.tree) tree.push(`<tr><td class="ip-cell">${esc(t.ip)}</td><td class="ip-cell">${esc(t.parent)}</td></tr>`);
|
|
document.getElementById('tree').innerHTML=tree.join('');
|
|
|
|
const downMbit = bytesPerSecToMbit(totalRx);
|
|
const upMbit = bytesPerSecToMbit(totalTx);
|
|
graphData.download.push(downMbit);
|
|
graphData.upload.push(upMbit);
|
|
if (graphData.download.length > graphData.maxPoints) {
|
|
graphData.download.shift();
|
|
graphData.upload.shift();
|
|
}
|
|
|
|
downSpeedEl.textContent = downMbit.toFixed(2) + ' Mbit/s';
|
|
upSpeedEl.textContent = upMbit.toFixed(2) + ' Mbit/s';
|
|
drawGraph();
|
|
}
|
|
|
|
setInterval(() => { reload().catch(console.error); }, 2000);
|
|
reload().catch(console.error);
|
|
window.addEventListener('resize', drawGraph);
|
|
</script>
|
|
</body>
|
|
</html>
|